diff --git a/apis/api-gateway/schema.graphql b/apis/api-gateway/schema.graphql index 5f2bc7c74fc..ae647e27c10 100644 --- a/apis/api-gateway/schema.graphql +++ b/apis/api-gateway/schema.graphql @@ -118,29 +118,22 @@ type Mutation @join__type(graph: API_ANALYTICS) @join__type(graph: API_JOURNEYS blockRestore is used for redo/undo """ blockRestore(id: ID!) : [Block!]! @join__field(graph: API_JOURNEYS) - chatButtonCreate(journeyId: ID!, input: ChatButtonCreateInput) : ChatButton! @join__field(graph: API_JOURNEYS_MODERN, override: "api-journeys") - chatButtonUpdate(id: ID!, journeyId: ID!, input: ChatButtonUpdateInput!) : ChatButton! @join__field(graph: API_JOURNEYS_MODERN, override: "api-journeys") - chatButtonRemove(id: ID!) : ChatButton! @join__field(graph: API_JOURNEYS) - customDomainCreate(input: CustomDomainCreateInput!) : CustomDomain! @join__field(graph: API_JOURNEYS_MODERN, override: "api-journeys") - customDomainUpdate(id: ID!, input: CustomDomainUpdateInput!) : CustomDomain! @join__field(graph: API_JOURNEYS) - customDomainDelete(id: ID!) : CustomDomain! @join__field(graph: API_JOURNEYS) - customDomainCheck(id: ID!) : CustomDomainCheck! @join__field(graph: API_JOURNEYS) """ Creates a JourneyViewEvent, returns null if attempting to create another JourneyViewEvent with the same userId, journeyId, and within the same 24hr period of the previous JourneyViewEvent """ - journeyViewEventCreate(input: JourneyViewEventCreateInput!) : JourneyViewEvent @join__field(graph: API_JOURNEYS) - stepViewEventCreate(input: StepViewEventCreateInput!) : StepViewEvent! @join__field(graph: API_JOURNEYS) + journeyViewEventCreate(input: JourneyViewEventCreateInput!) : JourneyViewEvent @join__field(graph: API_JOURNEYS_MODERN, override: "api-journeys") + stepViewEventCreate(input: StepViewEventCreateInput!) : StepViewEvent! @join__field(graph: API_JOURNEYS_MODERN, override: "api-journeys") stepNextEventCreate(input: StepNextEventCreateInput!) : StepNextEvent! @join__field(graph: API_JOURNEYS) stepPreviousEventCreate(input: StepPreviousEventCreateInput!) : StepPreviousEvent! @join__field(graph: API_JOURNEYS) - videoStartEventCreate(input: VideoStartEventCreateInput!) : VideoStartEvent! @join__field(graph: API_JOURNEYS) - videoPlayEventCreate(input: VideoPlayEventCreateInput!) : VideoPlayEvent! @join__field(graph: API_JOURNEYS) - videoPauseEventCreate(input: VideoPauseEventCreateInput!) : VideoPauseEvent! @join__field(graph: API_JOURNEYS) - videoCompleteEventCreate(input: VideoCompleteEventCreateInput!) : VideoCompleteEvent! @join__field(graph: API_JOURNEYS) - videoExpandEventCreate(input: VideoExpandEventCreateInput!) : VideoExpandEvent! @join__field(graph: API_JOURNEYS) - videoCollapseEventCreate(input: VideoCollapseEventCreateInput!) : VideoCollapseEvent! @join__field(graph: API_JOURNEYS) - videoProgressEventCreate(input: VideoProgressEventCreateInput!) : VideoProgressEvent! @join__field(graph: API_JOURNEYS) + videoStartEventCreate(input: VideoStartEventCreateInput!) : VideoStartEvent! @join__field(graph: API_JOURNEYS_MODERN, override: "api-journeys") + videoPlayEventCreate(input: VideoPlayEventCreateInput!) : VideoPlayEvent! @join__field(graph: API_JOURNEYS_MODERN, override: "api-journeys") + videoPauseEventCreate(input: VideoPauseEventCreateInput!) : VideoPauseEvent! @join__field(graph: API_JOURNEYS_MODERN, override: "api-journeys") + videoCompleteEventCreate(input: VideoCompleteEventCreateInput!) : VideoCompleteEvent! @join__field(graph: API_JOURNEYS_MODERN, override: "api-journeys") + videoExpandEventCreate(input: VideoExpandEventCreateInput!) : VideoExpandEvent! @join__field(graph: API_JOURNEYS_MODERN, override: "api-journeys") + videoCollapseEventCreate(input: VideoCollapseEventCreateInput!) : VideoCollapseEvent! @join__field(graph: API_JOURNEYS_MODERN, override: "api-journeys") + videoProgressEventCreate(input: VideoProgressEventCreateInput!) : VideoProgressEvent! @join__field(graph: API_JOURNEYS_MODERN, override: "api-journeys") hostCreate(teamId: ID!, input: HostCreateInput!) : Host! @join__field(graph: API_JOURNEYS) hostUpdate(id: ID!, teamId: ID!, input: HostUpdateInput) : Host! @join__field(graph: API_JOURNEYS) hostDelete(id: ID!, teamId: ID!) : Host! @join__field(graph: API_JOURNEYS) @@ -374,6 +367,13 @@ type Mutation @join__type(graph: API_ANALYTICS) @join__type(graph: API_JOURNEYS blockUpdateNavigateToBlockAction(id: ID!, input: NavigateToBlockActionInput!, journeyId: ID) : NavigateToBlockAction! @join__field(graph: API_JOURNEYS_MODERN) blockUpdatePhoneAction(id: ID!, input: PhoneActionInput!, journeyId: ID) : PhoneAction! @join__field(graph: API_JOURNEYS_MODERN) blockUpdateChatAction(id: ID!, input: ChatActionInput!, journeyId: ID) : ChatAction! @join__field(graph: API_JOURNEYS_MODERN) + chatButtonCreate(journeyId: ID!, input: ChatButtonCreateInput) : ChatButton! @join__field(graph: API_JOURNEYS_MODERN) + chatButtonRemove(id: ID!) : ChatButton! @join__field(graph: API_JOURNEYS_MODERN) + chatButtonUpdate(id: ID!, journeyId: ID!, input: ChatButtonUpdateInput!) : ChatButton! @join__field(graph: API_JOURNEYS_MODERN) + customDomainCheck(id: ID!) : CustomDomainCheck! @join__field(graph: API_JOURNEYS_MODERN) + customDomainDelete(id: ID!) : CustomDomain! @join__field(graph: API_JOURNEYS_MODERN) + customDomainUpdate(id: ID!, input: CustomDomainUpdateInput!) : CustomDomain! @join__field(graph: API_JOURNEYS_MODERN) + customDomainCreate(input: CustomDomainCreateInput!) : CustomDomain! @join__field(graph: API_JOURNEYS_MODERN) buttonClickEventCreate(input: ButtonClickEventCreateInput!) : ButtonClickEvent! @join__field(graph: API_JOURNEYS_MODERN) chatOpenEventCreate(input: ChatOpenEventCreateInput!) : ChatOpenEvent! @join__field(graph: API_JOURNEYS_MODERN) radioQuestionSubmissionEventCreate(input: RadioQuestionSubmissionEventCreateInput!) : RadioQuestionSubmissionEvent! @join__field(graph: API_JOURNEYS_MODERN) @@ -1103,285 +1103,6 @@ type CustomDomain @join__type(graph: API_JOURNEYS) @join__type(graph: API_JOURN routeAllTeamJourneys: Boolean! } -type CustomDomainCheck @join__type(graph: API_JOURNEYS) { - """ - Is the domain correctly configured in the DNS? - If false, A Record and CNAME Record should be added by the user. - """ - configured: Boolean! - """ - Does the domain belong to the team? - If false, verification and verificationResponse will be populated. - """ - verified: Boolean! - """ - Verification records to be added to the DNS to confirm ownership. - """ - verification: [CustomDomainVerification!] - """ - Reasoning as to why verification is required. - """ - verificationResponse: CustomDomainVerificationResponse -} - -type CustomDomainVerification @join__type(graph: API_JOURNEYS) { - type: String! - domain: String! - value: String! - reason: String! -} - -type CustomDomainVerificationResponse @join__type(graph: API_JOURNEYS) { - code: String! - message: String! -} - -type Query @join__type(graph: API_ANALYTICS) @join__type(graph: API_JOURNEYS) @join__type(graph: API_JOURNEYS_MODERN) @join__type(graph: API_LANGUAGES) @join__type(graph: API_MEDIA) @join__type(graph: API_USERS) { - customDomain(id: ID!) : CustomDomain! @join__field(graph: API_JOURNEYS_MODERN, override: "api-journeys") - customDomains(teamId: ID!) : [CustomDomain!]! @join__field(graph: API_JOURNEYS_MODERN, override: "api-journeys") - hosts(teamId: ID!) : [Host!]! @join__field(graph: API_JOURNEYS) - integrations(teamId: ID!) : [Integration!]! @join__field(graph: API_JOURNEYS) - adminJourneysReport(reportType: JourneysReportType!) : PowerBiEmbed @join__field(graph: API_JOURNEYS) - journeys(where: JourneysFilter, options: JourneysQueryOptions) : [Journey!]! @join__field(graph: API_JOURNEYS) - journey(id: ID!, idType: IdType, options: JourneysQueryOptions) : Journey! @join__field(graph: API_JOURNEYS) - """ - Returns distinct language IDs from published global templates. - Used to dynamically populate the language filter on the templates page. - """ - journeyTemplateLanguageIds: [String!]! @join__field(graph: API_JOURNEYS) - journeyCollection(id: ID!) : JourneyCollection! @join__field(graph: API_JOURNEYS) - journeyCollections(teamId: ID!) : [JourneyCollection]! @join__field(graph: API_JOURNEYS) - journeyEventsConnection(journeyId: ID!, filter: JourneyEventsFilter, first: Int, after: String) : JourneyEventsConnection! @join__field(graph: API_JOURNEYS) - journeyEventsCount(journeyId: ID!, filter: JourneyEventsFilter) : Int! @join__field(graph: API_JOURNEYS) - journeyTheme(journeyId: ID!) : JourneyTheme @join__field(graph: API_JOURNEYS) - """ - Get a list of Visitor Information by Journey - """ - journeyVisitorsConnection( - """ - Returns the elements in the list that match the specified filter. - """ - filter: JourneyVisitorFilter! - """ - Returns the first n elements from the list. - """ - first: Int - """ - Returns the elements in the list that come after the specified cursor. - """ - after: String - """ - Specifies the sort field for the list. - """ - sort: JourneyVisitorSort - ): JourneyVisitorsConnection! @join__field(graph: API_JOURNEYS) - """ - Get a JourneyVisitor count by JourneyVisitorFilter - """ - journeyVisitorCount(filter: JourneyVisitorFilter!) : Int! @join__field(graph: API_JOURNEYS) - journeysEmailPreference(email: String!) : JourneysEmailPreference @join__field(graph: API_JOURNEYS) - qrCode(id: ID!) : QrCode! @join__field(graph: API_JOURNEYS) - qrCodes(where: QrCodesFilter!) : [QrCode!]! @join__field(graph: API_JOURNEYS) - teams: [Team!]! @join__field(graph: API_JOURNEYS) - team(id: ID!) : Team! @join__field(graph: API_JOURNEYS) - userInvites(journeyId: ID!) : [UserInvite!] @join__field(graph: API_JOURNEYS) - userTeams(teamId: ID!, where: UserTeamFilterInput) : [UserTeam!]! @join__field(graph: API_JOURNEYS) - userTeam(id: ID!) : UserTeam! @join__field(graph: API_JOURNEYS) - userTeamInvites(teamId: ID!) : [UserTeamInvite!]! @join__field(graph: API_JOURNEYS) - """ - A list of visitors that are connected with a specific team. - """ - visitorsConnection( - """ - Returns the visitor items related to a specific team. - """ - teamId: String - """ - Returns the first n elements from the list. - """ - first: Int - """ - Returns the elements in the list that come after the specified cursor. - """ - after: String - ): VisitorsConnection! @join__field(graph: API_JOURNEYS) - """ - Get a single visitor - """ - visitor(id: ID!) : Visitor! @join__field(graph: API_JOURNEYS) - block(id: ID!) : Block! @join__field(graph: API_JOURNEYS_MODERN, override: "api-journeys") - blocks(where: BlocksFilter) : [Block!]! @join__field(graph: API_JOURNEYS_MODERN, override: "api-journeys") - node(id: ID!) : Node @join__field(graph: API_JOURNEYS_MODERN) - nodes(ids: [ID!]!) : [Node]! @join__field(graph: API_JOURNEYS_MODERN) - journeySimpleGet(id: ID!) : Json @join__field(graph: API_JOURNEYS_MODERN) - googleSheetsSyncs(filter: GoogleSheetsSyncsFilter!) : [GoogleSheetsSync!]! @join__field(graph: API_JOURNEYS_MODERN) - integrationGooglePickerToken(integrationId: ID!) : String! @join__field(graph: API_JOURNEYS_MODERN) - adminJourney(id: ID!, idType: IdType = slug) : Journey! @join__field(graph: API_JOURNEYS_MODERN) - adminJourneys( - status: [JourneyStatus!] - template: Boolean - teamId: ID - useLastActiveTeamId: Boolean - ): [Journey!]! @join__field(graph: API_JOURNEYS_MODERN) - getJourneyProfile: JourneyProfile @join__field(graph: API_JOURNEYS_MODERN) - """ - Returns a CSV formatted string with journey visitor export data including headers and visitor data with event information - """ - journeyVisitorExport( - journeyId: ID! - filter: JourneyEventsFilter - select: JourneyVisitorExportSelect - """ - IANA timezone identifier (e.g., "Pacific/Auckland"). Defaults to UTC if not provided. - """ - timezone: String - ): String @join__field(graph: API_JOURNEYS_MODERN) - journeysPlausibleStatsAggregate(where: PlausibleStatsAggregateFilter!, id: ID!, idType: IdType = slug) : PlausibleStatsAggregateResponse! @join__field(graph: API_JOURNEYS_MODERN) - """ - This endpoint allows you to break down your stats by some property. - If you are familiar with SQL family databases, this endpoint corresponds to - running `GROUP BY` on a certain property in your stats, then ordering by the - count. - Check out the [properties](https://plausible.io/docs/stats-api#properties) - section for a reference of all the properties you can use in this query. - This endpoint can be used to fetch data for `Top sources`, `Top pages`, - `Top countries` and similar reports. - Currently, it is only possible to break down on one property at a time. - Using a list of properties with one query is not supported. So if you want - a breakdown by both `event:page` and `visit:source` for example, you would - have to make multiple queries (break down on one property and filter on - another) and then manually/programmatically group the results together in one - report. This also applies for breaking down by time periods. To get a daily - breakdown for every page, you would have to break down on `event:page` and - make multiple queries for each date. - """ - journeysPlausibleStatsBreakdown(where: PlausibleStatsBreakdownFilter!, id: ID!, idType: IdType = slug) : [PlausibleStatsResponse!]! @join__field(graph: API_JOURNEYS_MODERN) - journeysPlausibleStatsRealtimeVisitors(id: ID!, idType: IdType = slug) : Int! @join__field(graph: API_JOURNEYS_MODERN) - """ - This endpoint provides timeseries data over a certain time period. - If you are familiar with the Plausible dashboard, this endpoint corresponds to the main visitor graph. - """ - journeysPlausibleStatsTimeseries(where: PlausibleStatsTimeseriesFilter!, id: ID!, idType: IdType = slug) : [PlausibleStatsResponse!]! @join__field(graph: API_JOURNEYS_MODERN) - templateFamilyStatsAggregate(id: ID!, idType: IdType = slug, where: PlausibleStatsAggregateFilter!) : TemplateFamilyStatsAggregateResponse @join__field(graph: API_JOURNEYS_MODERN) - templateFamilyStatsBreakdown( - id: ID! - idType: IdType = slug - where: PlausibleStatsBreakdownFilter! - """ - Filter results to only include the specified events. If null or empty, all events are returned. - """ - events: [PlausibleEvent!] - """ - Filter results to only include the specified status. If null or empty, all statuses are returned. - """ - status: [JourneyStatus!] - ): [TemplateFamilyStatsBreakdownResponse!] @join__field(graph: API_JOURNEYS_MODERN) - getUserRole: UserRole @join__field(graph: API_JOURNEYS_MODERN) - language(id: ID!, idType: LanguageIdType = databaseId) : Language @join__field(graph: API_LANGUAGES) - languages(offset: Int, limit: Int, where: LanguagesFilter, term: String) : [Language!]! @join__field(graph: API_LANGUAGES) - languagesCount(where: LanguagesFilter, term: String) : Int! @join__field(graph: API_LANGUAGES) - country(id: ID!) : Country @join__field(graph: API_LANGUAGES) - countries(term: String, ids: [ID!], where: CountriesFilter) : [Country!]! @join__field(graph: API_LANGUAGES) - getMyCloudflareImages(offset: Int, limit: Int) : [CloudflareImage!]! @join__field(graph: API_MEDIA) - getMyCloudflareImage(id: ID!) : CloudflareImage! @join__field(graph: API_MEDIA) - listUnsplashCollectionPhotos( - collectionId: String! - page: Int - perPage: Int - orientation: UnsplashPhotoOrientation - ): [UnsplashPhoto!]! @join__field(graph: API_MEDIA) - searchUnsplashPhotos( - query: String! - page: Int - perPage: Int - orderBy: UnsplashOrderBy - collections: [String!] - contentFilter: UnsplashContentFilter - color: UnsplashColor - orientation: UnsplashPhotoOrientation - ): UnsplashQueryResponse! @join__field(graph: API_MEDIA) - bibleBooks(where: BibleBooksFilter) : [BibleBook!]! @join__field(graph: API_MEDIA) - bibleCitations(videoId: ID) : [BibleCitation!]! @join__field(graph: API_MEDIA) - bibleCitation(id: ID!) : BibleCitation! @join__field(graph: API_MEDIA) - keywords(where: KeywordsFilter) : [Keyword!]! @join__field(graph: API_MEDIA) - getMyMuxVideos(offset: Int, limit: Int) : [MuxVideo!]! @join__field(graph: API_MEDIA) - getMyMuxVideo(id: ID!, userGenerated: Boolean) : MuxVideo! @join__field(graph: API_MEDIA) - getMuxVideo(id: ID!, userGenerated: Boolean) : MuxVideo @join__field(graph: API_MEDIA) - getMyGeneratedMuxSubtitleTrack(muxVideoId: ID!, bcp47: String!, userGenerated: Boolean) : QueryGetMyGeneratedMuxSubtitleTrackResult! @join__field(graph: API_MEDIA) - playlists: [Playlist!] @join__field(graph: API_MEDIA) - playlist(id: ID!, idType: IdType! = databaseId) : QueryPlaylistResult @join__field(graph: API_MEDIA) - """ - List of short link domains that can be used for short links - """ - shortLinkDomains( - """ - Filter by service (including domains with no services set) - """ - service: Service - before: String - after: String - first: Int - last: Int - ): QueryShortLinkDomainsConnection! @join__field(graph: API_MEDIA) - """ - Find a short link domain by id - """ - shortLinkDomain(id: String!) : QueryShortLinkDomainResult! @join__field(graph: API_MEDIA) - """ - find a short link by path and hostname - """ - shortLinkByPath( - """ - short link path not including the leading slash - """ - pathname: String! - """ - the hostname including subdomain, domain, and TLD, but excluding port - """ - hostname: String! - ): QueryShortLinkByPathResult! @join__field(graph: API_MEDIA) - """ - find a short link by id - """ - shortLink(id: String!) : QueryShortLinkResult! @join__field(graph: API_MEDIA) - """ - find all short links with optional hostname filter - """ - shortLinks( - """ - the hostname including subdomain, domain, and TLD, but excluding port - """ - hostname: String - before: String - after: String - first: Int - last: Int - ): QueryShortLinksConnection! @join__field(graph: API_MEDIA) - userMediaProfile: UserMediaProfile @join__field(graph: API_MEDIA) - videoVariant(id: ID!) : VideoVariant! @join__field(graph: API_MEDIA) - videoVariants(input: VideoVariantFilter, offset: Int, limit: Int) : [VideoVariant!]! @join__field(graph: API_MEDIA) - videoVariantsCount(input: VideoVariantFilter) : Int! @join__field(graph: API_MEDIA) - adminVideo(id: ID!, idType: IdType = databaseId) : Video! @join__field(graph: API_MEDIA) - adminVideos(where: VideosFilter, offset: Int, limit: Int) : [Video!]! @join__field(graph: API_MEDIA) - adminVideosCount(where: VideosFilter) : Int! @join__field(graph: API_MEDIA) - video(id: ID!, idType: IdType = databaseId) : Video! @join__field(graph: API_MEDIA) - videos(where: VideosFilter, offset: Int, limit: Int) : [Video!]! @join__field(graph: API_MEDIA) - videosCount(where: VideosFilter) : Int! @join__field(graph: API_MEDIA) - checkVideoInAlgolia(videoId: ID!) : CheckVideoInAlgoliaResult! @join__field(graph: API_MEDIA) - checkVideoVariantsInAlgolia(videoId: ID!) : CheckVideoVariantsInAlgoliaResult! @join__field(graph: API_MEDIA) - videoOrigins: [VideoOrigin!]! @join__field(graph: API_MEDIA) - videoEditions: [VideoEdition!]! @join__field(graph: API_MEDIA) - videoEdition(id: ID!) : VideoEdition @join__field(graph: API_MEDIA) - tags: [Tag!]! @join__field(graph: API_MEDIA) - taxonomies(category: String, languageCodes: [String!]) : [Taxonomy!]! @join__field(graph: API_MEDIA) - youtubeClosedCaptionLanguages(videoId: ID!) : QueryYoutubeClosedCaptionLanguagesResult! @join__field(graph: API_MEDIA) - arclightApiKeys: [ArclightApiKey!]! @join__field(graph: API_MEDIA) - arclightApiKeyByKey(key: String!) : ArclightApiKey @join__field(graph: API_MEDIA) - me(input: MeInput) : User @join__field(graph: API_USERS) - user(id: ID!) : AuthenticatedUser @join__field(graph: API_USERS) - userByEmail(email: String!) : AuthenticatedUser @join__field(graph: API_USERS) -} - type ButtonClickEvent implements Event @join__type(graph: API_JOURNEYS) @join__type(graph: API_JOURNEYS_MODERN) @join__implements(graph: API_JOURNEYS, interface: "Event") @join__implements(graph: API_JOURNEYS_MODERN, interface: "Event") { id: ID! """ @@ -1783,50 +1504,296 @@ type VideoCollapseEvent implements Event @join__type(graph: API_JOURNEYS) @join """ position: Float """ - source of the video (based on the source in the value field) + source of the video (based on the source in the value field) + """ + source: VideoBlockSource +} + +type VideoProgressEvent implements Event @join__type(graph: API_JOURNEYS) @join__type(graph: API_JOURNEYS_MODERN) @join__implements(graph: API_JOURNEYS, interface: "Event") @join__implements(graph: API_JOURNEYS_MODERN, interface: "Event") { + id: ID! + """ + ID of the journey that the videoBlock belongs to + """ + journeyId: ID! + """ + time event was created + """ + createdAt: DateTime! + """ + title of the video + """ + label: String + """ + source of the video + """ + value: String + """ + duration of the video played when the VideoProgressEvent is triggered + """ + position: Float + """ + source of the video (based on the source in the value field) + """ + source: VideoBlockSource + """ + progress is a integer indicating the precentage completion from the startAt to the endAt times of the videoBlock + """ + progress: Int! +} + +type Host @join__type(graph: API_JOURNEYS) @join__type(graph: API_JOURNEYS_MODERN) { + id: ID! + teamId: ID! + title: String! + location: String + src1: String + src2: String +} + +type Query @join__type(graph: API_ANALYTICS) @join__type(graph: API_JOURNEYS) @join__type(graph: API_JOURNEYS_MODERN) @join__type(graph: API_LANGUAGES) @join__type(graph: API_MEDIA) @join__type(graph: API_USERS) { + hosts(teamId: ID!) : [Host!]! @join__field(graph: API_JOURNEYS) + integrations(teamId: ID!) : [Integration!]! @join__field(graph: API_JOURNEYS) + adminJourneysReport(reportType: JourneysReportType!) : PowerBiEmbed @join__field(graph: API_JOURNEYS) + journeys(where: JourneysFilter, options: JourneysQueryOptions) : [Journey!]! @join__field(graph: API_JOURNEYS) + journey(id: ID!, idType: IdType, options: JourneysQueryOptions) : Journey! @join__field(graph: API_JOURNEYS) + """ + Returns distinct language IDs from published global templates. + Used to dynamically populate the language filter on the templates page. + """ + journeyTemplateLanguageIds: [String!]! @join__field(graph: API_JOURNEYS) + journeyCollection(id: ID!) : JourneyCollection! @join__field(graph: API_JOURNEYS) + journeyCollections(teamId: ID!) : [JourneyCollection]! @join__field(graph: API_JOURNEYS) + journeyEventsConnection(journeyId: ID!, filter: JourneyEventsFilter, first: Int, after: String) : JourneyEventsConnection! @join__field(graph: API_JOURNEYS) + journeyEventsCount(journeyId: ID!, filter: JourneyEventsFilter) : Int! @join__field(graph: API_JOURNEYS) + journeyTheme(journeyId: ID!) : JourneyTheme @join__field(graph: API_JOURNEYS) + """ + Get a list of Visitor Information by Journey + """ + journeyVisitorsConnection( + """ + Returns the elements in the list that match the specified filter. + """ + filter: JourneyVisitorFilter! + """ + Returns the first n elements from the list. + """ + first: Int + """ + Returns the elements in the list that come after the specified cursor. + """ + after: String + """ + Specifies the sort field for the list. + """ + sort: JourneyVisitorSort + ): JourneyVisitorsConnection! @join__field(graph: API_JOURNEYS) + """ + Get a JourneyVisitor count by JourneyVisitorFilter + """ + journeyVisitorCount(filter: JourneyVisitorFilter!) : Int! @join__field(graph: API_JOURNEYS) + journeysEmailPreference(email: String!) : JourneysEmailPreference @join__field(graph: API_JOURNEYS) + qrCode(id: ID!) : QrCode! @join__field(graph: API_JOURNEYS) + qrCodes(where: QrCodesFilter!) : [QrCode!]! @join__field(graph: API_JOURNEYS) + teams: [Team!]! @join__field(graph: API_JOURNEYS) + team(id: ID!) : Team! @join__field(graph: API_JOURNEYS) + userInvites(journeyId: ID!) : [UserInvite!] @join__field(graph: API_JOURNEYS) + userTeams(teamId: ID!, where: UserTeamFilterInput) : [UserTeam!]! @join__field(graph: API_JOURNEYS) + userTeam(id: ID!) : UserTeam! @join__field(graph: API_JOURNEYS) + userTeamInvites(teamId: ID!) : [UserTeamInvite!]! @join__field(graph: API_JOURNEYS) + """ + A list of visitors that are connected with a specific team. + """ + visitorsConnection( + """ + Returns the visitor items related to a specific team. + """ + teamId: String + """ + Returns the first n elements from the list. + """ + first: Int + """ + Returns the elements in the list that come after the specified cursor. + """ + after: String + ): VisitorsConnection! @join__field(graph: API_JOURNEYS) + """ + Get a single visitor + """ + visitor(id: ID!) : Visitor! @join__field(graph: API_JOURNEYS) + block(id: ID!) : Block! @join__field(graph: API_JOURNEYS_MODERN, override: "api-journeys") + blocks(where: BlocksFilter) : [Block!]! @join__field(graph: API_JOURNEYS_MODERN, override: "api-journeys") + customDomains(teamId: ID!) : [CustomDomain!]! @join__field(graph: API_JOURNEYS_MODERN) + customDomain(id: ID!) : CustomDomain! @join__field(graph: API_JOURNEYS_MODERN) + node(id: ID!) : Node @join__field(graph: API_JOURNEYS_MODERN) + nodes(ids: [ID!]!) : [Node]! @join__field(graph: API_JOURNEYS_MODERN) + journeySimpleGet(id: ID!) : Json @join__field(graph: API_JOURNEYS_MODERN) + googleSheetsSyncs(filter: GoogleSheetsSyncsFilter!) : [GoogleSheetsSync!]! @join__field(graph: API_JOURNEYS_MODERN) + integrationGooglePickerToken(integrationId: ID!) : String! @join__field(graph: API_JOURNEYS_MODERN) + adminJourney(id: ID!, idType: IdType = slug) : Journey! @join__field(graph: API_JOURNEYS_MODERN) + adminJourneys( + status: [JourneyStatus!] + template: Boolean + teamId: ID + useLastActiveTeamId: Boolean + ): [Journey!]! @join__field(graph: API_JOURNEYS_MODERN) + getJourneyProfile: JourneyProfile @join__field(graph: API_JOURNEYS_MODERN) + """ + Returns a CSV formatted string with journey visitor export data including headers and visitor data with event information """ - source: VideoBlockSource -} - -type VideoProgressEvent implements Event @join__type(graph: API_JOURNEYS) @join__type(graph: API_JOURNEYS_MODERN) @join__implements(graph: API_JOURNEYS, interface: "Event") @join__implements(graph: API_JOURNEYS_MODERN, interface: "Event") { - id: ID! + journeyVisitorExport( + journeyId: ID! + filter: JourneyEventsFilter + select: JourneyVisitorExportSelect + """ + IANA timezone identifier (e.g., "Pacific/Auckland"). Defaults to UTC if not provided. + """ + timezone: String + ): String @join__field(graph: API_JOURNEYS_MODERN) + journeysPlausibleStatsAggregate(where: PlausibleStatsAggregateFilter!, id: ID!, idType: IdType = slug) : PlausibleStatsAggregateResponse! @join__field(graph: API_JOURNEYS_MODERN) """ - ID of the journey that the videoBlock belongs to + This endpoint allows you to break down your stats by some property. + If you are familiar with SQL family databases, this endpoint corresponds to + running `GROUP BY` on a certain property in your stats, then ordering by the + count. + Check out the [properties](https://plausible.io/docs/stats-api#properties) + section for a reference of all the properties you can use in this query. + This endpoint can be used to fetch data for `Top sources`, `Top pages`, + `Top countries` and similar reports. + Currently, it is only possible to break down on one property at a time. + Using a list of properties with one query is not supported. So if you want + a breakdown by both `event:page` and `visit:source` for example, you would + have to make multiple queries (break down on one property and filter on + another) and then manually/programmatically group the results together in one + report. This also applies for breaking down by time periods. To get a daily + breakdown for every page, you would have to break down on `event:page` and + make multiple queries for each date. """ - journeyId: ID! + journeysPlausibleStatsBreakdown(where: PlausibleStatsBreakdownFilter!, id: ID!, idType: IdType = slug) : [PlausibleStatsResponse!]! @join__field(graph: API_JOURNEYS_MODERN) + journeysPlausibleStatsRealtimeVisitors(id: ID!, idType: IdType = slug) : Int! @join__field(graph: API_JOURNEYS_MODERN) """ - time event was created + This endpoint provides timeseries data over a certain time period. + If you are familiar with the Plausible dashboard, this endpoint corresponds to the main visitor graph. """ - createdAt: DateTime! + journeysPlausibleStatsTimeseries(where: PlausibleStatsTimeseriesFilter!, id: ID!, idType: IdType = slug) : [PlausibleStatsResponse!]! @join__field(graph: API_JOURNEYS_MODERN) + templateFamilyStatsAggregate(id: ID!, idType: IdType = slug, where: PlausibleStatsAggregateFilter!) : TemplateFamilyStatsAggregateResponse @join__field(graph: API_JOURNEYS_MODERN) + templateFamilyStatsBreakdown( + id: ID! + idType: IdType = slug + where: PlausibleStatsBreakdownFilter! + """ + Filter results to only include the specified events. If null or empty, all events are returned. + """ + events: [PlausibleEvent!] + """ + Filter results to only include the specified status. If null or empty, all statuses are returned. + """ + status: [JourneyStatus!] + ): [TemplateFamilyStatsBreakdownResponse!] @join__field(graph: API_JOURNEYS_MODERN) + getUserRole: UserRole @join__field(graph: API_JOURNEYS_MODERN) + language(id: ID!, idType: LanguageIdType = databaseId) : Language @join__field(graph: API_LANGUAGES) + languages(offset: Int, limit: Int, where: LanguagesFilter, term: String) : [Language!]! @join__field(graph: API_LANGUAGES) + languagesCount(where: LanguagesFilter, term: String) : Int! @join__field(graph: API_LANGUAGES) + country(id: ID!) : Country @join__field(graph: API_LANGUAGES) + countries(term: String, ids: [ID!], where: CountriesFilter) : [Country!]! @join__field(graph: API_LANGUAGES) + getMyCloudflareImages(offset: Int, limit: Int) : [CloudflareImage!]! @join__field(graph: API_MEDIA) + getMyCloudflareImage(id: ID!) : CloudflareImage! @join__field(graph: API_MEDIA) + listUnsplashCollectionPhotos( + collectionId: String! + page: Int + perPage: Int + orientation: UnsplashPhotoOrientation + ): [UnsplashPhoto!]! @join__field(graph: API_MEDIA) + searchUnsplashPhotos( + query: String! + page: Int + perPage: Int + orderBy: UnsplashOrderBy + collections: [String!] + contentFilter: UnsplashContentFilter + color: UnsplashColor + orientation: UnsplashPhotoOrientation + ): UnsplashQueryResponse! @join__field(graph: API_MEDIA) + bibleBooks(where: BibleBooksFilter) : [BibleBook!]! @join__field(graph: API_MEDIA) + bibleCitations(videoId: ID) : [BibleCitation!]! @join__field(graph: API_MEDIA) + bibleCitation(id: ID!) : BibleCitation! @join__field(graph: API_MEDIA) + keywords(where: KeywordsFilter) : [Keyword!]! @join__field(graph: API_MEDIA) + getMyMuxVideos(offset: Int, limit: Int) : [MuxVideo!]! @join__field(graph: API_MEDIA) + getMyMuxVideo(id: ID!, userGenerated: Boolean) : MuxVideo! @join__field(graph: API_MEDIA) + getMuxVideo(id: ID!, userGenerated: Boolean) : MuxVideo @join__field(graph: API_MEDIA) + getMyGeneratedMuxSubtitleTrack(muxVideoId: ID!, bcp47: String!, userGenerated: Boolean) : QueryGetMyGeneratedMuxSubtitleTrackResult! @join__field(graph: API_MEDIA) + playlists: [Playlist!] @join__field(graph: API_MEDIA) + playlist(id: ID!, idType: IdType! = databaseId) : QueryPlaylistResult @join__field(graph: API_MEDIA) """ - title of the video + List of short link domains that can be used for short links """ - label: String + shortLinkDomains( + """ + Filter by service (including domains with no services set) + """ + service: Service + before: String + after: String + first: Int + last: Int + ): QueryShortLinkDomainsConnection! @join__field(graph: API_MEDIA) """ - source of the video + Find a short link domain by id """ - value: String + shortLinkDomain(id: String!) : QueryShortLinkDomainResult! @join__field(graph: API_MEDIA) """ - duration of the video played when the VideoProgressEvent is triggered + find a short link by path and hostname """ - position: Float + shortLinkByPath( + """ + short link path not including the leading slash + """ + pathname: String! + """ + the hostname including subdomain, domain, and TLD, but excluding port + """ + hostname: String! + ): QueryShortLinkByPathResult! @join__field(graph: API_MEDIA) """ - source of the video (based on the source in the value field) + find a short link by id """ - source: VideoBlockSource + shortLink(id: String!) : QueryShortLinkResult! @join__field(graph: API_MEDIA) """ - progress is a integer indicating the precentage completion from the startAt to the endAt times of the videoBlock + find all short links with optional hostname filter """ - progress: Int! -} - -type Host @join__type(graph: API_JOURNEYS) @join__type(graph: API_JOURNEYS_MODERN) { - id: ID! - teamId: ID! - title: String! - location: String - src1: String - src2: String + shortLinks( + """ + the hostname including subdomain, domain, and TLD, but excluding port + """ + hostname: String + before: String + after: String + first: Int + last: Int + ): QueryShortLinksConnection! @join__field(graph: API_MEDIA) + userMediaProfile: UserMediaProfile @join__field(graph: API_MEDIA) + videoVariant(id: ID!) : VideoVariant! @join__field(graph: API_MEDIA) + videoVariants(input: VideoVariantFilter, offset: Int, limit: Int) : [VideoVariant!]! @join__field(graph: API_MEDIA) + videoVariantsCount(input: VideoVariantFilter) : Int! @join__field(graph: API_MEDIA) + adminVideo(id: ID!, idType: IdType = databaseId) : Video! @join__field(graph: API_MEDIA) + adminVideos(where: VideosFilter, offset: Int, limit: Int) : [Video!]! @join__field(graph: API_MEDIA) + adminVideosCount(where: VideosFilter) : Int! @join__field(graph: API_MEDIA) + video(id: ID!, idType: IdType = databaseId) : Video! @join__field(graph: API_MEDIA) + videos(where: VideosFilter, offset: Int, limit: Int) : [Video!]! @join__field(graph: API_MEDIA) + videosCount(where: VideosFilter) : Int! @join__field(graph: API_MEDIA) + checkVideoInAlgolia(videoId: ID!) : CheckVideoInAlgoliaResult! @join__field(graph: API_MEDIA) + checkVideoVariantsInAlgolia(videoId: ID!) : CheckVideoVariantsInAlgoliaResult! @join__field(graph: API_MEDIA) + videoOrigins: [VideoOrigin!]! @join__field(graph: API_MEDIA) + videoEditions: [VideoEdition!]! @join__field(graph: API_MEDIA) + videoEdition(id: ID!) : VideoEdition @join__field(graph: API_MEDIA) + tags: [Tag!]! @join__field(graph: API_MEDIA) + taxonomies(category: String, languageCodes: [String!]) : [Taxonomy!]! @join__field(graph: API_MEDIA) + youtubeClosedCaptionLanguages(videoId: ID!) : QueryYoutubeClosedCaptionLanguagesResult! @join__field(graph: API_MEDIA) + arclightApiKeys: [ArclightApiKey!]! @join__field(graph: API_MEDIA) + arclightApiKeyByKey(key: String!) : ArclightApiKey @join__field(graph: API_MEDIA) + me(input: MeInput) : User @join__field(graph: API_USERS) + user(id: ID!) : AuthenticatedUser @join__field(graph: API_USERS) + userByEmail(email: String!) : AuthenticatedUser @join__field(graph: API_USERS) } type IntegrationGoogle implements Integration @join__type(graph: API_JOURNEYS) @join__type(graph: API_JOURNEYS_MODERN) @join__implements(graph: API_JOURNEYS, interface: "Integration") @join__implements(graph: API_JOURNEYS_MODERN, interface: "Integration") { @@ -2365,6 +2332,25 @@ type VisitorsConnection @join__type(graph: API_JOURNEYS) { pageInfo: PageInfo! } +type CustomDomainCheck @join__type(graph: API_JOURNEYS_MODERN) { + configured: Boolean! + verified: Boolean! + verification: [CustomDomainVerification!] + verificationResponse: CustomDomainVerificationResponse +} + +type CustomDomainVerification @join__type(graph: API_JOURNEYS_MODERN) { + type: String! + domain: String! + value: String! + reason: String! +} + +type CustomDomainVerificationResponse @join__type(graph: API_JOURNEYS_MODERN) { + code: String! + message: String! +} + type GoogleSheetsSync @join__type(graph: API_JOURNEYS_MODERN) { id: ID! teamId: ID! @@ -3681,6 +3667,14 @@ enum MessagePlatform @join__type(graph: API_JOURNEYS) @join__type(graph: API_JO weChat @join__enumValue(graph: API_JOURNEYS) @join__enumValue(graph: API_JOURNEYS_MODERN) } +enum ButtonAction @join__type(graph: API_JOURNEYS) @join__type(graph: API_JOURNEYS_MODERN) { + NavigateToBlockAction @join__enumValue(graph: API_JOURNEYS) @join__enumValue(graph: API_JOURNEYS_MODERN) + LinkAction @join__enumValue(graph: API_JOURNEYS) @join__enumValue(graph: API_JOURNEYS_MODERN) + EmailAction @join__enumValue(graph: API_JOURNEYS) @join__enumValue(graph: API_JOURNEYS_MODERN) + PhoneAction @join__enumValue(graph: API_JOURNEYS) @join__enumValue(graph: API_JOURNEYS_MODERN) + ChatAction @join__enumValue(graph: API_JOURNEYS) @join__enumValue(graph: API_JOURNEYS_MODERN) +} + enum JourneysReportType @join__type(graph: API_JOURNEYS) @join__type(graph: API_JOURNEYS_MODERN) { multipleFull @join__enumValue(graph: API_JOURNEYS) @join__enumValue(graph: API_JOURNEYS_MODERN) multipleSummary @join__enumValue(graph: API_JOURNEYS) @join__enumValue(graph: API_JOURNEYS_MODERN) @@ -3694,14 +3688,6 @@ enum JourneyVisitorSort @join__type(graph: API_JOURNEYS) @join__type(graph: API activity @join__enumValue(graph: API_JOURNEYS) @join__enumValue(graph: API_JOURNEYS_MODERN) } -enum ButtonAction @join__type(graph: API_JOURNEYS) @join__type(graph: API_JOURNEYS_MODERN) { - NavigateToBlockAction @join__enumValue(graph: API_JOURNEYS) @join__enumValue(graph: API_JOURNEYS_MODERN) - LinkAction @join__enumValue(graph: API_JOURNEYS) @join__enumValue(graph: API_JOURNEYS_MODERN) - EmailAction @join__enumValue(graph: API_JOURNEYS) @join__enumValue(graph: API_JOURNEYS_MODERN) - PhoneAction @join__enumValue(graph: API_JOURNEYS) @join__enumValue(graph: API_JOURNEYS_MODERN) - ChatAction @join__enumValue(graph: API_JOURNEYS) @join__enumValue(graph: API_JOURNEYS_MODERN) -} - enum IntegrationType @join__type(graph: API_JOURNEYS) @join__type(graph: API_JOURNEYS_MODERN) { google @join__enumValue(graph: API_JOURNEYS) @join__enumValue(graph: API_JOURNEYS_MODERN) growthSpaces @join__enumValue(graph: API_JOURNEYS) @join__enumValue(graph: API_JOURNEYS_MODERN) @@ -3982,30 +3968,6 @@ input TypographyBlockSettingsInput @join__type(graph: API_JOURNEYS) @join__type color: String } -input ChatButtonCreateInput @join__type(graph: API_JOURNEYS) @join__type(graph: API_JOURNEYS_MODERN) { - link: String - platform: MessagePlatform -} - -input ChatButtonUpdateInput @join__type(graph: API_JOURNEYS) @join__type(graph: API_JOURNEYS_MODERN) { - link: String - platform: MessagePlatform - customizable: Boolean -} - -input CustomDomainCreateInput @join__type(graph: API_JOURNEYS) @join__type(graph: API_JOURNEYS_MODERN) { - id: ID - teamId: String! - name: String! - journeyCollectionId: ID - routeAllTeamJourneys: Boolean -} - -input CustomDomainUpdateInput @join__type(graph: API_JOURNEYS) @join__type(graph: API_JOURNEYS_MODERN) { - journeyCollectionId: ID - routeAllTeamJourneys: Boolean -} - input JourneyViewEventCreateInput @join__type(graph: API_JOURNEYS) @join__type(graph: API_JOURNEYS_MODERN) { """ ID should be unique Event UUID (Provided for optimistic mutation result matching) @@ -4635,6 +4597,17 @@ input ChatActionInput @join__type(graph: API_JOURNEYS_MODERN) { parentStepId: String } +input ChatButtonCreateInput @join__type(graph: API_JOURNEYS_MODERN) { + link: String + platform: MessagePlatform +} + +input ChatButtonUpdateInput @join__type(graph: API_JOURNEYS_MODERN) { + link: String + platform: MessagePlatform + customizable: Boolean +} + input ChatOpenEventCreateInput @join__type(graph: API_JOURNEYS_MODERN) { """ ID should be unique Event UUID (Provided for optimistic mutation result matching) @@ -4659,6 +4632,19 @@ input CreateGoogleSheetsSyncInput @join__type(graph: API_JOURNEYS_MODERN) { folderId: String } +input CustomDomainCreateInput @join__type(graph: API_JOURNEYS_MODERN) { + id: ID + teamId: String! + name: String! + journeyCollectionId: ID + routeAllTeamJourneys: Boolean +} + +input CustomDomainUpdateInput @join__type(graph: API_JOURNEYS_MODERN) { + journeyCollectionId: ID + routeAllTeamJourneys: Boolean +} + input EmailActionInput @join__type(graph: API_JOURNEYS_MODERN) { gtmEventName: String email: String! diff --git a/apis/api-journeys-modern/schema.graphql b/apis/api-journeys-modern/schema.graphql index 50d66eb95a7..565beccc049 100644 --- a/apis/api-journeys-modern/schema.graphql +++ b/apis/api-journeys-modern/schema.graphql @@ -346,6 +346,15 @@ type CustomDomain journeyCollection: JourneyCollection } +type CustomDomainCheck + @shareable +{ + configured: Boolean! + verified: Boolean! + verification: [CustomDomainVerification!] + verificationResponse: CustomDomainVerificationResponse +} + input CustomDomainCreateInput { id: ID teamId: String! @@ -359,6 +368,22 @@ input CustomDomainUpdateInput { routeAllTeamJourneys: Boolean } +type CustomDomainVerification + @shareable +{ + type: String! + domain: String! + value: String! + reason: String! +} + +type CustomDomainVerificationResponse + @shareable +{ + code: String! + message: String! +} + """ A date string, such as 2007-12-03, compliant with the `full-date` format outlined in section 5.6 of the RFC 3339 profile of the ISO 8601 standard for representation of dates and times using the Gregorian calendar. """ @@ -1518,15 +1543,28 @@ type Mutation { blockUpdateNavigateToBlockAction(id: ID!, input: NavigateToBlockActionInput!, journeyId: ID): NavigateToBlockAction! blockUpdatePhoneAction(id: ID!, input: PhoneActionInput!, journeyId: ID): PhoneAction! blockUpdateChatAction(id: ID!, input: ChatActionInput!, journeyId: ID): ChatAction! - chatButtonCreate(journeyId: ID!, input: ChatButtonCreateInput): ChatButton! @override(from: "api-journeys") - chatButtonUpdate(id: ID!, journeyId: ID!, input: ChatButtonUpdateInput!): ChatButton! @override(from: "api-journeys") - customDomainCreate(input: CustomDomainCreateInput!): CustomDomain! @override(from: "api-journeys") + chatButtonCreate(journeyId: ID!, input: ChatButtonCreateInput): ChatButton! + chatButtonRemove(id: ID!): ChatButton! + chatButtonUpdate(id: ID!, journeyId: ID!, input: ChatButtonUpdateInput!): ChatButton! + customDomainCheck(id: ID!): CustomDomainCheck! + customDomainDelete(id: ID!): CustomDomain! + customDomainUpdate(id: ID!, input: CustomDomainUpdateInput!): CustomDomain! + customDomainCreate(input: CustomDomainCreateInput!): CustomDomain! buttonClickEventCreate(input: ButtonClickEventCreateInput!): ButtonClickEvent! chatOpenEventCreate(input: ChatOpenEventCreateInput!): ChatOpenEvent! + journeyViewEventCreate(input: JourneyViewEventCreateInput!): JourneyViewEvent @override(from: "api-journeys") + stepViewEventCreate(input: StepViewEventCreateInput!): StepViewEvent! @override(from: "api-journeys") radioQuestionSubmissionEventCreate(input: RadioQuestionSubmissionEventCreateInput!): RadioQuestionSubmissionEvent! multiselectSubmissionEventCreate(input: MultiselectSubmissionEventCreateInput!): MultiselectSubmissionEvent! signUpSubmissionEventCreate(input: SignUpSubmissionEventCreateInput!): SignUpSubmissionEvent! textResponseSubmissionEventCreate(input: TextResponseSubmissionEventCreateInput!): TextResponseSubmissionEvent! + videoStartEventCreate(input: VideoStartEventCreateInput!): VideoStartEvent! @override(from: "api-journeys") + videoPlayEventCreate(input: VideoPlayEventCreateInput!): VideoPlayEvent! @override(from: "api-journeys") + videoPauseEventCreate(input: VideoPauseEventCreateInput!): VideoPauseEvent! @override(from: "api-journeys") + videoCompleteEventCreate(input: VideoCompleteEventCreateInput!): VideoCompleteEvent! @override(from: "api-journeys") + videoExpandEventCreate(input: VideoExpandEventCreateInput!): VideoExpandEvent! @override(from: "api-journeys") + videoCollapseEventCreate(input: VideoCollapseEventCreateInput!): VideoCollapseEvent! @override(from: "api-journeys") + videoProgressEventCreate(input: VideoProgressEventCreateInput!): VideoProgressEvent! @override(from: "api-journeys") journeySimpleUpdate(id: ID!, journey: Json!): Json googleSheetsSyncCreate(input: CreateGoogleSheetsSyncInput!): GoogleSheetsSync! googleSheetsSyncDelete(id: ID!): GoogleSheetsSync! @@ -1871,8 +1909,8 @@ input QrCodesFilter { type Query { block(id: ID!): Block! @override(from: "api-journeys") blocks(where: BlocksFilter): [Block!]! @override(from: "api-journeys") - customDomains(teamId: ID!): [CustomDomain!]! @override(from: "api-journeys") - customDomain(id: ID!): CustomDomain! @override(from: "api-journeys") + customDomains(teamId: ID!): [CustomDomain!]! + customDomain(id: ID!): CustomDomain! node(id: ID!): Node nodes(ids: [ID!]!): [Node]! journeySimpleGet(id: ID!): Json diff --git a/apis/api-journeys-modern/src/schema/chatButton/chatButtonCreate.mutation.ts b/apis/api-journeys-modern/src/schema/chatButton/chatButtonCreate.mutation.ts index 894b1d061eb..8dc05b4593b 100644 --- a/apis/api-journeys-modern/src/schema/chatButton/chatButtonCreate.mutation.ts +++ b/apis/api-journeys-modern/src/schema/chatButton/chatButtonCreate.mutation.ts @@ -13,7 +13,6 @@ builder.mutationField('chatButtonCreate', (t) => .prismaField({ type: ChatButtonRef, nullable: false, - override: { from: 'api-journeys' }, args: { journeyId: t.arg({ type: 'ID', required: true }), input: t.arg({ type: ChatButtonCreateInput, required: false }) diff --git a/apis/api-journeys-modern/src/schema/chatButton/chatButtonRemove.mutation.spec.ts b/apis/api-journeys-modern/src/schema/chatButton/chatButtonRemove.mutation.spec.ts new file mode 100644 index 00000000000..299f4eb9386 --- /dev/null +++ b/apis/api-journeys-modern/src/schema/chatButton/chatButtonRemove.mutation.spec.ts @@ -0,0 +1,142 @@ +import { getUserFromPayload } from '@core/yoga/firebaseClient' + +import { getClient } from '../../../test/client' +import { prismaMock } from '../../../test/prismaMock' +import { graphql } from '../../lib/graphql/subgraphGraphql' +import { recalculateJourneyCustomizable } from '../../lib/recalculateJourneyCustomizable/recalculateJourneyCustomizable' + +jest.mock('@core/yoga/firebaseClient', () => ({ + getUserFromPayload: jest.fn() +})) + +jest.mock( + '../../lib/recalculateJourneyCustomizable/recalculateJourneyCustomizable', + () => ({ + recalculateJourneyCustomizable: jest.fn() + }) +) + +const mockGetUserFromPayload = getUserFromPayload as jest.MockedFunction< + typeof getUserFromPayload +> + +const mockRecalculate = recalculateJourneyCustomizable as jest.MockedFunction< + typeof recalculateJourneyCustomizable +> + +describe('chatButtonRemove', () => { + const mockUser = { + id: 'userId', + firstName: 'Test', + emailVerified: true + } + const authClient = getClient({ + headers: { authorization: 'token' }, + context: { currentUser: mockUser } + }) + + const CHAT_BUTTON_REMOVE = graphql(` + mutation ChatButtonRemove($id: ID!) { + chatButtonRemove(id: $id) { + id + link + platform + customizable + } + } + `) + + beforeEach(() => { + jest.clearAllMocks() + mockGetUserFromPayload.mockReturnValue(mockUser) + prismaMock.userRole.findUnique.mockResolvedValue({ + id: 'userRoleId', + userId: mockUser.id, + roles: [] + }) + }) + + it('removes a chat button when authorized', async () => { + prismaMock.chatButton.delete.mockResolvedValue({ + id: 'chatButtonId', + journeyId: 'journeyId', + link: 'https://m.me/user', + platform: 'facebook', + customizable: true + } as any) + + const result = await authClient({ + document: CHAT_BUTTON_REMOVE, + variables: { id: 'chatButtonId' } + }) + + expect(result).toEqual({ + data: { + chatButtonRemove: { + id: 'chatButtonId', + link: 'https://m.me/user', + platform: 'facebook', + customizable: true + } + } + }) + + expect(prismaMock.chatButton.delete).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: 'chatButtonId' } + }) + ) + + expect(mockRecalculate).toHaveBeenCalledWith('journeyId') + }) + + it('removes a chat button with null fields', async () => { + prismaMock.chatButton.delete.mockResolvedValue({ + id: 'chatButtonId', + journeyId: 'journeyId', + link: null, + platform: null, + customizable: null + } as any) + + const result = await authClient({ + document: CHAT_BUTTON_REMOVE, + variables: { id: 'chatButtonId' } + }) + + expect(result).toEqual({ + data: { + chatButtonRemove: { + id: 'chatButtonId', + link: null, + platform: null, + customizable: null + } + } + }) + + expect(mockRecalculate).toHaveBeenCalledWith('journeyId') + }) + + it('throws error when user is not authenticated', async () => { + mockGetUserFromPayload.mockReturnValue(null) + const unauthClient = getClient({ + headers: { authorization: 'token' }, + context: { currentUser: null } + }) + + const result = await unauthClient({ + document: CHAT_BUTTON_REMOVE, + variables: { id: 'chatButtonId' } + }) + + expect(result).toEqual({ + data: null, + errors: [ + expect.objectContaining({ + message: expect.stringContaining('Not authorized') + }) + ] + }) + }) +}) diff --git a/apis/api-journeys-modern/src/schema/chatButton/chatButtonRemove.mutation.ts b/apis/api-journeys-modern/src/schema/chatButton/chatButtonRemove.mutation.ts new file mode 100644 index 00000000000..4e2a6364b4c --- /dev/null +++ b/apis/api-journeys-modern/src/schema/chatButton/chatButtonRemove.mutation.ts @@ -0,0 +1,30 @@ +import { prisma } from '@core/prisma/journeys/client' + +import { recalculateJourneyCustomizable } from '../../lib/recalculateJourneyCustomizable/recalculateJourneyCustomizable' +import { builder } from '../builder' + +import { ChatButtonRef } from './chatButton' + +builder.mutationField('chatButtonRemove', (t) => + t + .withAuth({ $any: { isAuthenticated: true, isAnonymous: true } }) + .prismaField({ + type: ChatButtonRef, + nullable: false, + args: { + id: t.arg({ type: 'ID', required: true }) + }, + resolve: async (query, _parent, args, _context) => { + const { id } = args + + const result = await prisma.chatButton.delete({ + ...query, + where: { id } + }) + + await recalculateJourneyCustomizable(result.journeyId) + + return result + } + }) +) diff --git a/apis/api-journeys-modern/src/schema/chatButton/chatButtonUpdate.mutation.ts b/apis/api-journeys-modern/src/schema/chatButton/chatButtonUpdate.mutation.ts index 1298d973494..c77f520d892 100644 --- a/apis/api-journeys-modern/src/schema/chatButton/chatButtonUpdate.mutation.ts +++ b/apis/api-journeys-modern/src/schema/chatButton/chatButtonUpdate.mutation.ts @@ -12,7 +12,6 @@ builder.mutationField('chatButtonUpdate', (t) => .prismaField({ type: ChatButtonRef, nullable: false, - override: { from: 'api-journeys' }, args: { id: t.arg({ type: 'ID', required: true }), journeyId: t.arg({ type: 'ID', required: true }), diff --git a/apis/api-journeys-modern/src/schema/chatButton/index.ts b/apis/api-journeys-modern/src/schema/chatButton/index.ts index 93d676a7d01..74540222bc9 100644 --- a/apis/api-journeys-modern/src/schema/chatButton/index.ts +++ b/apis/api-journeys-modern/src/schema/chatButton/index.ts @@ -1,4 +1,5 @@ import './chatButton' import './chatButtonCreate.mutation' +import './chatButtonRemove.mutation' import './chatButtonUpdate.mutation' import './inputs' diff --git a/apis/api-journeys-modern/src/schema/customDomain/customDomain.query.ts b/apis/api-journeys-modern/src/schema/customDomain/customDomain.query.ts index 15222321188..2dd52039794 100644 --- a/apis/api-journeys-modern/src/schema/customDomain/customDomain.query.ts +++ b/apis/api-journeys-modern/src/schema/customDomain/customDomain.query.ts @@ -12,7 +12,6 @@ builder.queryField('customDomain', (t) => t.withAuth({ isAuthenticated: true }).prismaField({ type: CustomDomainRef, nullable: false, - override: { from: 'api-journeys' }, args: { id: t.arg({ type: 'ID', required: true }) }, diff --git a/apis/api-journeys-modern/src/schema/customDomain/customDomain.service.ts b/apis/api-journeys-modern/src/schema/customDomain/customDomain.service.ts new file mode 100644 index 00000000000..f7d9633ca0b --- /dev/null +++ b/apis/api-journeys-modern/src/schema/customDomain/customDomain.service.ts @@ -0,0 +1,127 @@ +import { ApolloClient, InMemoryCache, createHttpLink } from '@apollo/client' +import { GraphQLError } from 'graphql' + +import { CustomDomain, prisma } from '@core/prisma/journeys/client' +import { graphql } from '@core/shared/gql' + +import { env } from '../../env' + +const UPDATE_SHORT_LINK = graphql(` + mutation CustomDomainServiceShortLinkUpdate( + $input: MutationShortLinkUpdateInput! + ) { + shortLinkUpdate(input: $input) { + ... on ZodError { + message + } + ... on NotFoundError { + message + } + ... on MutationShortLinkUpdateSuccess { + data { + id + to + } + } + } + } +`) + +function createApolloClient(): ApolloClient { + const httpLink = createHttpLink({ + uri: env.GATEWAY_URL, + headers: { + 'interop-token': env.INTEROP_TOKEN, + 'x-graphql-client-name': 'api-journeys-modern', + 'x-graphql-client-version': env.SERVICE_VERSION + } + }) + + return new ApolloClient({ + link: httpLink, + cache: new InMemoryCache() + }) +} + +export async function deleteVercelDomain({ + name +}: CustomDomain): Promise { + if (process.env.VERCEL_JOURNEYS_PROJECT_ID == null) return true + + const response = await fetch( + `https://api.vercel.com/v9/projects/${process.env.VERCEL_JOURNEYS_PROJECT_ID}/domains/${name}?teamId=${process.env.VERCEL_TEAM_ID}`, + { + headers: { + Authorization: `Bearer ${process.env.VERCEL_TOKEN}` + }, + method: 'DELETE' + } + ) + + switch (response.status) { + case 200: + case 404: + return true + default: + throw new GraphQLError('vercel response not handled', { + extensions: { code: 'INTERNAL_SERVER_ERROR' } + }) + } +} + +async function buildJourneyUrl( + shortLinkId: string, + teamId: string, + journeyId: string, + blockId?: string | null +): Promise { + const journey = await prisma.journey.findUniqueOrThrow({ + where: { id: journeyId } + }) + + const customDomain = ( + await prisma.customDomain.findMany({ + where: { teamId } + }) + )[0] + + const base = + customDomain?.name != null + ? `https://${customDomain.name}` + : env.JOURNEYS_URL + + const blockPath = blockId != null ? `/${blockId}` : '' + const path = `${journey.slug}${blockPath}` + const utm = `?utm_source=ns-qr-code&utm_campaign=${shortLinkId}` + + return `${base}/${path}${utm}` +} + +export async function updateTeamShortLinks( + teamId: string, + customDomainName: string +): Promise { + const apollo = createApolloClient() + + const qrCodes = await prisma.qrCode.findMany({ + where: { teamId } + }) + + for (const qrCode of qrCodes) { + if (qrCode.journeyId !== qrCode.toJourneyId) continue + + const to = await buildJourneyUrl( + qrCode.id, + qrCode.teamId, + qrCode.toJourneyId, + qrCode.toBlockId + ) + + await apollo.mutate({ + mutation: UPDATE_SHORT_LINK, + variables: { + input: { id: qrCode.shortLinkId, to } + } + }) + } +} diff --git a/apis/api-journeys-modern/src/schema/customDomain/customDomainCheck.mutation.spec.ts b/apis/api-journeys-modern/src/schema/customDomain/customDomainCheck.mutation.spec.ts new file mode 100644 index 00000000000..43dc01a1d1f --- /dev/null +++ b/apis/api-journeys-modern/src/schema/customDomain/customDomainCheck.mutation.spec.ts @@ -0,0 +1,243 @@ +import { UserTeamRole } from '@core/prisma/journeys/client' +import { getUserFromPayload } from '@core/yoga/firebaseClient' + +import { getClient } from '../../../test/client' +import { prismaMock } from '../../../test/prismaMock' +import { graphql } from '../../lib/graphql/subgraphGraphql' + +jest.mock('@core/yoga/firebaseClient', () => ({ + getUserFromPayload: jest.fn() +})) + +jest.mock('./service', () => ({ + ...jest.requireActual('./service'), + checkVercelDomain: jest.fn() +})) + +const mockGetUserFromPayload = getUserFromPayload as jest.MockedFunction< + typeof getUserFromPayload +> + +describe('customDomainCheck', () => { + const mockUser = { id: 'userId', email: 'test@example.com' } + + const authClient = getClient({ + headers: { authorization: 'token' }, + context: { currentUser: mockUser } + }) + + const CUSTOM_DOMAIN_CHECK_MUTATION = graphql(` + mutation CustomDomainCheck($id: ID!) { + customDomainCheck(id: $id) { + configured + verified + verification { + type + domain + value + reason + } + verificationResponse { + code + message + } + } + } + `) + + const mockCustomDomain = { + id: 'customDomainId', + teamId: 'teamId', + name: 'example.com', + apexName: 'example.com', + journeyCollectionId: null, + routeAllTeamJourneys: true, + team: { + id: 'teamId', + userTeams: [ + { + id: 'userTeamId', + teamId: 'teamId', + userId: 'userId', + role: UserTeamRole.manager, + createdAt: new Date(), + updatedAt: new Date() + } + ] + } + } + + const { checkVercelDomain } = require('./service') + + beforeEach(() => { + jest.clearAllMocks() + mockGetUserFromPayload.mockReturnValue(mockUser as any) + prismaMock.userRole.findUnique.mockResolvedValue({ + userId: mockUser.id, + roles: [] + } as any) + }) + + it('should return configured and verified when domain is healthy', async () => { + prismaMock.customDomain.findUnique.mockResolvedValue( + mockCustomDomain as any + ) + checkVercelDomain.mockResolvedValue({ + configured: true, + verified: true + }) + + const result = await authClient({ + document: CUSTOM_DOMAIN_CHECK_MUTATION, + variables: { id: 'customDomainId' } + }) + + expect(result).toEqual({ + data: { + customDomainCheck: { + configured: true, + verified: true, + verification: null, + verificationResponse: null + } + } + }) + + expect(checkVercelDomain).toHaveBeenCalledWith('example.com') + }) + + it('should return verification details when domain is not verified', async () => { + prismaMock.customDomain.findUnique.mockResolvedValue( + mockCustomDomain as any + ) + checkVercelDomain.mockResolvedValue({ + configured: false, + verified: false, + verification: [ + { + type: 'TXT', + domain: '_vercel.example.com', + value: 'vc-domain-verify=example123', + reason: 'pending_domain_verification' + } + ], + verificationResponse: { + code: 'missing_txt_record', + message: 'Missing TXT record' + } + }) + + const result = await authClient({ + document: CUSTOM_DOMAIN_CHECK_MUTATION, + variables: { id: 'customDomainId' } + }) + + expect(result).toEqual({ + data: { + customDomainCheck: { + configured: false, + verified: false, + verification: [ + { + type: 'TXT', + domain: '_vercel.example.com', + value: 'vc-domain-verify=example123', + reason: 'pending_domain_verification' + } + ], + verificationResponse: { + code: 'missing_txt_record', + message: 'Missing TXT record' + } + } + } + }) + }) + + it('should return NOT_FOUND when custom domain does not exist', async () => { + prismaMock.customDomain.findUnique.mockResolvedValue(null) + + const result = await authClient({ + document: CUSTOM_DOMAIN_CHECK_MUTATION, + variables: { id: 'nonExistentId' } + }) + + expect(result).toEqual({ + data: null, + errors: [ + expect.objectContaining({ + message: 'custom domain not found' + }) + ] + }) + }) + + it('should return FORBIDDEN when user is not a team manager', async () => { + const unauthorizedCustomDomain = { + ...mockCustomDomain, + team: { + id: 'teamId', + userTeams: [ + { + id: 'userTeamId', + teamId: 'teamId', + userId: 'userId', + role: UserTeamRole.member, + createdAt: new Date(), + updatedAt: new Date() + } + ] + } + } + + prismaMock.customDomain.findUnique.mockResolvedValue( + unauthorizedCustomDomain as any + ) + + const result = await authClient({ + document: CUSTOM_DOMAIN_CHECK_MUTATION, + variables: { id: 'customDomainId' } + }) + + expect(result).toEqual({ + data: null, + errors: [ + expect.objectContaining({ + message: 'user is not allowed to check custom domain' + }) + ] + }) + + expect(checkVercelDomain).not.toHaveBeenCalled() + }) + + it('should return FORBIDDEN when user is not in the team', async () => { + const noAccessCustomDomain = { + ...mockCustomDomain, + team: { + id: 'teamId', + userTeams: [] + } + } + + prismaMock.customDomain.findUnique.mockResolvedValue( + noAccessCustomDomain as any + ) + + const result = await authClient({ + document: CUSTOM_DOMAIN_CHECK_MUTATION, + variables: { id: 'customDomainId' } + }) + + expect(result).toEqual({ + data: null, + errors: [ + expect.objectContaining({ + message: 'user is not allowed to check custom domain' + }) + ] + }) + + expect(checkVercelDomain).not.toHaveBeenCalled() + }) +}) diff --git a/apis/api-journeys-modern/src/schema/customDomain/customDomainCheck.mutation.ts b/apis/api-journeys-modern/src/schema/customDomain/customDomainCheck.mutation.ts new file mode 100644 index 00000000000..656a2adf763 --- /dev/null +++ b/apis/api-journeys-modern/src/schema/customDomain/customDomainCheck.mutation.ts @@ -0,0 +1,42 @@ +import { GraphQLError } from 'graphql' + +import { prisma } from '@core/prisma/journeys/client' + +import { Action } from '../../lib/auth/ability' +import { builder } from '../builder' + +import { canAccessCustomDomain } from './customDomain.acl' +import { CustomDomainCheck } from './customDomainCheck' +import { checkVercelDomain } from './service' + +builder.mutationField('customDomainCheck', (t) => + t.withAuth({ $any: { isAuthenticated: true, isAnonymous: true } }).field({ + type: CustomDomainCheck, + nullable: false, + args: { + id: t.arg({ type: 'ID', required: true }) + }, + resolve: async (_parent, args, context) => { + const { id } = args + + const customDomain = await prisma.customDomain.findUnique({ + where: { id }, + include: { team: { include: { userTeams: true } } } + }) + + if (customDomain == null) { + throw new GraphQLError('custom domain not found', { + extensions: { code: 'NOT_FOUND' } + }) + } + + if (!canAccessCustomDomain(Action.Manage, customDomain, context.user)) { + throw new GraphQLError('user is not allowed to check custom domain', { + extensions: { code: 'FORBIDDEN' } + }) + } + + return await checkVercelDomain(customDomain.name) + } + }) +) diff --git a/apis/api-journeys-modern/src/schema/customDomain/customDomainCheck.ts b/apis/api-journeys-modern/src/schema/customDomain/customDomainCheck.ts new file mode 100644 index 00000000000..0dbd55fc2b5 --- /dev/null +++ b/apis/api-journeys-modern/src/schema/customDomain/customDomainCheck.ts @@ -0,0 +1,63 @@ +import { builder } from '../builder' + +import type { CustomDomainCheckResult } from './service' + +interface VerificationShape { + type: string + domain: string + value: string + reason: string +} + +interface VerificationResponseShape { + code: string + message: string +} + +const CustomDomainVerification = builder.objectRef( + 'CustomDomainVerification' +) + +builder.objectType(CustomDomainVerification, { + shareable: true, + fields: (t) => ({ + type: t.exposeString('type', { nullable: false }), + domain: t.exposeString('domain', { nullable: false }), + value: t.exposeString('value', { nullable: false }), + reason: t.exposeString('reason', { nullable: false }) + }) +}) + +const CustomDomainVerificationResponse = + builder.objectRef( + 'CustomDomainVerificationResponse' + ) + +builder.objectType(CustomDomainVerificationResponse, { + shareable: true, + fields: (t) => ({ + code: t.exposeString('code', { nullable: false }), + message: t.exposeString('message', { nullable: false }) + }) +}) + +export const CustomDomainCheck = + builder.objectRef('CustomDomainCheck') + +builder.objectType(CustomDomainCheck, { + shareable: true, + fields: (t) => ({ + configured: t.exposeBoolean('configured', { nullable: false }), + verified: t.exposeBoolean('verified', { nullable: false }), + verification: t.field({ + type: [CustomDomainVerification], + nullable: true, + resolve: (parent) => parent.verification ?? null + }), + verificationResponse: t.field({ + type: CustomDomainVerificationResponse, + nullable: true, + resolve: (parent) => parent.verificationResponse ?? null + }) + }) +}) diff --git a/apis/api-journeys-modern/src/schema/customDomain/customDomainCreate.mutation.ts b/apis/api-journeys-modern/src/schema/customDomain/customDomainCreate.mutation.ts index 28226263722..ca127e15f6c 100644 --- a/apis/api-journeys-modern/src/schema/customDomain/customDomainCreate.mutation.ts +++ b/apis/api-journeys-modern/src/schema/customDomain/customDomainCreate.mutation.ts @@ -27,7 +27,6 @@ builder.mutationField('customDomainCreate', (t) => .prismaField({ type: CustomDomainRef, nullable: false, - override: { from: 'api-journeys' }, args: { input: t.arg({ type: CustomDomainCreateInput, required: true }) }, diff --git a/apis/api-journeys-modern/src/schema/customDomain/customDomainDelete.mutation.spec.ts b/apis/api-journeys-modern/src/schema/customDomain/customDomainDelete.mutation.spec.ts new file mode 100644 index 00000000000..63f0b8f6662 --- /dev/null +++ b/apis/api-journeys-modern/src/schema/customDomain/customDomainDelete.mutation.spec.ts @@ -0,0 +1,201 @@ +import { UserTeamRole } from '@core/prisma/journeys/client' +import { getUserFromPayload } from '@core/yoga/firebaseClient' + +import { getClient } from '../../../test/client' +import { prismaMock } from '../../../test/prismaMock' +import { graphql } from '../../lib/graphql/subgraphGraphql' + +jest.mock('@core/yoga/firebaseClient', () => ({ + getUserFromPayload: jest.fn() +})) + +jest.mock('./customDomain.service', () => ({ + deleteVercelDomain: jest.fn().mockResolvedValue(true), + updateTeamShortLinks: jest.fn().mockResolvedValue(undefined) +})) + +const mockGetUserFromPayload = getUserFromPayload as jest.MockedFunction< + typeof getUserFromPayload +> + +describe('customDomainDelete', () => { + const mockUser = { id: 'userId', email: 'test@example.com' } + + const authClient = getClient({ + headers: { authorization: 'token' }, + context: { currentUser: mockUser } + }) + + const CUSTOM_DOMAIN_DELETE_MUTATION = graphql(` + mutation CustomDomainDelete($id: ID!) { + customDomainDelete(id: $id) { + id + name + apexName + routeAllTeamJourneys + } + } + `) + + const mockCustomDomain = { + id: 'customDomainId', + teamId: 'teamId', + name: 'example.com', + apexName: 'example.com', + journeyCollectionId: null, + routeAllTeamJourneys: true, + team: { + id: 'teamId', + userTeams: [ + { + id: 'userTeamId', + teamId: 'teamId', + userId: 'userId', + role: UserTeamRole.manager, + createdAt: new Date(), + updatedAt: new Date() + } + ] + } + } + + const { + deleteVercelDomain, + updateTeamShortLinks + } = require('./customDomain.service') + + beforeEach(() => { + jest.clearAllMocks() + mockGetUserFromPayload.mockReturnValue(mockUser as any) + prismaMock.userRole.findUnique.mockResolvedValue({ + userId: mockUser.id, + roles: [] + } as any) + prismaMock.$transaction.mockImplementation(async (fn: any) => + fn(prismaMock) + ) + }) + + it('should delete custom domain when authorized', async () => { + prismaMock.customDomain.findUnique.mockResolvedValue( + mockCustomDomain as any + ) + prismaMock.customDomain.delete.mockResolvedValue(mockCustomDomain as any) + + const result = await authClient({ + document: CUSTOM_DOMAIN_DELETE_MUTATION, + variables: { id: 'customDomainId' } + }) + + expect(result).toEqual({ + data: { + customDomainDelete: { + id: 'customDomainId', + name: 'example.com', + apexName: 'example.com', + routeAllTeamJourneys: true + } + } + }) + + expect(updateTeamShortLinks).toHaveBeenCalledWith('teamId', 'example.com') + expect(prismaMock.customDomain.delete).toHaveBeenCalledWith({ + where: { id: 'customDomainId' } + }) + expect(deleteVercelDomain).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'customDomainId', + name: 'example.com' + }) + ) + }) + + it('should return NOT_FOUND when custom domain does not exist', async () => { + prismaMock.customDomain.findUnique.mockResolvedValue(null) + + const result = await authClient({ + document: CUSTOM_DOMAIN_DELETE_MUTATION, + variables: { id: 'nonExistentId' } + }) + + expect(result).toEqual({ + data: null, + errors: [ + expect.objectContaining({ + message: 'custom domain not found' + }) + ] + }) + }) + + it('should return FORBIDDEN when user is not a team manager', async () => { + const unauthorizedCustomDomain = { + ...mockCustomDomain, + team: { + id: 'teamId', + userTeams: [ + { + id: 'userTeamId', + teamId: 'teamId', + userId: 'userId', + role: UserTeamRole.member, + createdAt: new Date(), + updatedAt: new Date() + } + ] + } + } + + prismaMock.customDomain.findUnique.mockResolvedValue( + unauthorizedCustomDomain as any + ) + + const result = await authClient({ + document: CUSTOM_DOMAIN_DELETE_MUTATION, + variables: { id: 'customDomainId' } + }) + + expect(result).toEqual({ + data: null, + errors: [ + expect.objectContaining({ + message: 'user is not allowed to delete custom domain' + }) + ] + }) + + expect(prismaMock.customDomain.delete).not.toHaveBeenCalled() + expect(deleteVercelDomain).not.toHaveBeenCalled() + }) + + it('should return FORBIDDEN when user is not in the team', async () => { + const noAccessCustomDomain = { + ...mockCustomDomain, + team: { + id: 'teamId', + userTeams: [] + } + } + + prismaMock.customDomain.findUnique.mockResolvedValue( + noAccessCustomDomain as any + ) + + const result = await authClient({ + document: CUSTOM_DOMAIN_DELETE_MUTATION, + variables: { id: 'customDomainId' } + }) + + expect(result).toEqual({ + data: null, + errors: [ + expect.objectContaining({ + message: 'user is not allowed to delete custom domain' + }) + ] + }) + + expect(prismaMock.customDomain.delete).not.toHaveBeenCalled() + expect(deleteVercelDomain).not.toHaveBeenCalled() + }) +}) diff --git a/apis/api-journeys-modern/src/schema/customDomain/customDomainDelete.mutation.ts b/apis/api-journeys-modern/src/schema/customDomain/customDomainDelete.mutation.ts new file mode 100644 index 00000000000..77d2b07beb2 --- /dev/null +++ b/apis/api-journeys-modern/src/schema/customDomain/customDomainDelete.mutation.ts @@ -0,0 +1,51 @@ +import { GraphQLError } from 'graphql' + +import { prisma } from '@core/prisma/journeys/client' + +import { Action } from '../../lib/auth/ability' +import { builder } from '../builder' + +import { CustomDomainRef } from './customDomain' +import { canAccessCustomDomain } from './customDomain.acl' +import { + deleteVercelDomain, + updateTeamShortLinks +} from './customDomain.service' + +builder.mutationField('customDomainDelete', (t) => + t.withAuth({ $any: { isAuthenticated: true, isAnonymous: true } }).field({ + type: CustomDomainRef, + nullable: false, + args: { + id: t.arg({ type: 'ID', required: true }) + }, + resolve: async (_parent, args, context) => { + const { id } = args + + const customDomain = await prisma.customDomain.findUnique({ + where: { id }, + include: { team: { include: { userTeams: true } } } + }) + + if (customDomain == null) { + throw new GraphQLError('custom domain not found', { + extensions: { code: 'NOT_FOUND' } + }) + } + + if (!canAccessCustomDomain(Action.Delete, customDomain, context.user)) { + throw new GraphQLError('user is not allowed to delete custom domain', { + extensions: { code: 'FORBIDDEN' } + }) + } + + await prisma.$transaction(async (tx) => { + await updateTeamShortLinks(customDomain.teamId, customDomain.name) + await tx.customDomain.delete({ where: { id } }) + await deleteVercelDomain(customDomain) + }) + + return customDomain + } + }) +) diff --git a/apis/api-journeys-modern/src/schema/customDomain/customDomainUpdate.mutation.spec.ts b/apis/api-journeys-modern/src/schema/customDomain/customDomainUpdate.mutation.spec.ts new file mode 100644 index 00000000000..45d60dbb191 --- /dev/null +++ b/apis/api-journeys-modern/src/schema/customDomain/customDomainUpdate.mutation.spec.ts @@ -0,0 +1,314 @@ +import { UserTeamRole } from '@core/prisma/journeys/client' +import { getUserFromPayload } from '@core/yoga/firebaseClient' + +import { getClient } from '../../../test/client' +import { prismaMock } from '../../../test/prismaMock' +import { graphql } from '../../lib/graphql/subgraphGraphql' + +jest.mock('@core/yoga/firebaseClient', () => ({ + getUserFromPayload: jest.fn() +})) + +const mockGetUserFromPayload = getUserFromPayload as jest.MockedFunction< + typeof getUserFromPayload +> + +describe('customDomainUpdate', () => { + const mockUser = { id: 'userId', email: 'test@example.com' } + + const authClient = getClient({ + headers: { authorization: 'token' }, + context: { currentUser: mockUser } + }) + + const CUSTOM_DOMAIN_UPDATE_MUTATION = graphql(` + mutation CustomDomainUpdate($id: ID!, $input: CustomDomainUpdateInput!) { + customDomainUpdate(id: $id, input: $input) { + id + name + apexName + routeAllTeamJourneys + } + } + `) + + const mockCustomDomain = { + id: 'customDomainId', + teamId: 'teamId', + name: 'example.com', + apexName: 'example.com', + journeyCollectionId: null, + routeAllTeamJourneys: true, + team: { + id: 'teamId', + userTeams: [ + { + id: 'userTeamId', + teamId: 'teamId', + userId: 'userId', + role: UserTeamRole.manager, + createdAt: new Date(), + updatedAt: new Date() + } + ] + } + } + + beforeEach(() => { + jest.clearAllMocks() + mockGetUserFromPayload.mockReturnValue(mockUser as any) + prismaMock.userRole.findUnique.mockResolvedValue({ + userId: mockUser.id, + roles: [] + } as any) + }) + + it('should update routeAllTeamJourneys', async () => { + prismaMock.customDomain.findUnique.mockResolvedValue( + mockCustomDomain as any + ) + prismaMock.customDomain.update.mockResolvedValue({ + ...mockCustomDomain, + routeAllTeamJourneys: false + } as any) + + const result = await authClient({ + document: CUSTOM_DOMAIN_UPDATE_MUTATION, + variables: { + id: 'customDomainId', + input: { routeAllTeamJourneys: false } + } + }) + + expect(result).toEqual({ + data: { + customDomainUpdate: { + id: 'customDomainId', + name: 'example.com', + apexName: 'example.com', + routeAllTeamJourneys: false + } + } + }) + + expect(prismaMock.customDomain.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: 'customDomainId' }, + data: { + routeAllTeamJourneys: false, + journeyCollection: undefined + } + }) + ) + }) + + it('should update journeyCollectionId', async () => { + prismaMock.customDomain.findUnique.mockResolvedValue( + mockCustomDomain as any + ) + prismaMock.journeyCollection.findFirst.mockResolvedValue({ + id: 'collectionId', + teamId: 'teamId' + } as any) + prismaMock.customDomain.update.mockResolvedValue({ + ...mockCustomDomain, + journeyCollectionId: 'collectionId' + } as any) + + const result = await authClient({ + document: CUSTOM_DOMAIN_UPDATE_MUTATION, + variables: { + id: 'customDomainId', + input: { journeyCollectionId: 'collectionId' } + } + }) + + expect(result).toEqual({ + data: { + customDomainUpdate: { + id: 'customDomainId', + name: 'example.com', + apexName: 'example.com', + routeAllTeamJourneys: true + } + } + }) + + expect(prismaMock.journeyCollection.findFirst).toHaveBeenCalledWith({ + where: { id: 'collectionId', teamId: 'teamId' } + }) + expect(prismaMock.customDomain.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: 'customDomainId' }, + data: { + routeAllTeamJourneys: undefined, + journeyCollection: { connect: { id: 'collectionId' } } + } + }) + ) + }) + + it('should disconnect journeyCollection when journeyCollectionId is null', async () => { + prismaMock.customDomain.findUnique.mockResolvedValue( + mockCustomDomain as any + ) + prismaMock.customDomain.update.mockResolvedValue({ + ...mockCustomDomain, + journeyCollectionId: null + } as any) + + const result = await authClient({ + document: CUSTOM_DOMAIN_UPDATE_MUTATION, + variables: { + id: 'customDomainId', + input: { journeyCollectionId: null } + } + }) + + expect(result).toEqual({ + data: { + customDomainUpdate: { + id: 'customDomainId', + name: 'example.com', + apexName: 'example.com', + routeAllTeamJourneys: true + } + } + }) + + expect(prismaMock.customDomain.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: 'customDomainId' }, + data: { + routeAllTeamJourneys: undefined, + journeyCollection: { disconnect: true } + } + }) + ) + }) + + it('should return FORBIDDEN when journeyCollectionId belongs to another team', async () => { + prismaMock.customDomain.findUnique.mockResolvedValue( + mockCustomDomain as any + ) + prismaMock.journeyCollection.findFirst.mockResolvedValue(null) + + const result = await authClient({ + document: CUSTOM_DOMAIN_UPDATE_MUTATION, + variables: { + id: 'customDomainId', + input: { journeyCollectionId: 'otherTeamCollectionId' } + } + }) + + expect(result).toEqual({ + data: null, + errors: [ + expect.objectContaining({ + message: 'journey collection not found for this custom domain team' + }) + ] + }) + + expect(prismaMock.journeyCollection.findFirst).toHaveBeenCalledWith({ + where: { id: 'otherTeamCollectionId', teamId: 'teamId' } + }) + expect(prismaMock.customDomain.update).not.toHaveBeenCalled() + }) + + it('should return NOT_FOUND when custom domain does not exist', async () => { + prismaMock.customDomain.findUnique.mockResolvedValue(null) + + const result = await authClient({ + document: CUSTOM_DOMAIN_UPDATE_MUTATION, + variables: { + id: 'nonExistentId', + input: { routeAllTeamJourneys: false } + } + }) + + expect(result).toEqual({ + data: null, + errors: [ + expect.objectContaining({ + message: 'custom domain not found' + }) + ] + }) + }) + + it('should return FORBIDDEN when user is not a team manager', async () => { + const unauthorizedCustomDomain = { + ...mockCustomDomain, + team: { + id: 'teamId', + userTeams: [ + { + id: 'userTeamId', + teamId: 'teamId', + userId: 'userId', + role: UserTeamRole.member, + createdAt: new Date(), + updatedAt: new Date() + } + ] + } + } + + prismaMock.customDomain.findUnique.mockResolvedValue( + unauthorizedCustomDomain as any + ) + + const result = await authClient({ + document: CUSTOM_DOMAIN_UPDATE_MUTATION, + variables: { + id: 'customDomainId', + input: { routeAllTeamJourneys: false } + } + }) + + expect(result).toEqual({ + data: null, + errors: [ + expect.objectContaining({ + message: 'user is not allowed to update custom domain' + }) + ] + }) + + expect(prismaMock.customDomain.update).not.toHaveBeenCalled() + }) + + it('should return FORBIDDEN when user is not in the team', async () => { + const noAccessCustomDomain = { + ...mockCustomDomain, + team: { + id: 'teamId', + userTeams: [] + } + } + + prismaMock.customDomain.findUnique.mockResolvedValue( + noAccessCustomDomain as any + ) + + const result = await authClient({ + document: CUSTOM_DOMAIN_UPDATE_MUTATION, + variables: { + id: 'customDomainId', + input: { routeAllTeamJourneys: false } + } + }) + + expect(result).toEqual({ + data: null, + errors: [ + expect.objectContaining({ + message: 'user is not allowed to update custom domain' + }) + ] + }) + + expect(prismaMock.customDomain.update).not.toHaveBeenCalled() + }) +}) diff --git a/apis/api-journeys-modern/src/schema/customDomain/customDomainUpdate.mutation.ts b/apis/api-journeys-modern/src/schema/customDomain/customDomainUpdate.mutation.ts new file mode 100644 index 00000000000..6212e034b1e --- /dev/null +++ b/apis/api-journeys-modern/src/schema/customDomain/customDomainUpdate.mutation.ts @@ -0,0 +1,79 @@ +import { GraphQLError } from 'graphql' + +import { prisma } from '@core/prisma/journeys/client' + +import { Action } from '../../lib/auth/ability' +import { builder } from '../builder' + +import { CustomDomainRef } from './customDomain' +import { canAccessCustomDomain } from './customDomain.acl' +import { CustomDomainUpdateInput } from './inputs' + +builder.mutationField('customDomainUpdate', (t) => + t.withAuth({ isAuthenticated: true }).prismaField({ + type: CustomDomainRef, + nullable: false, + args: { + id: t.arg({ type: 'ID', required: true }), + input: t.arg({ type: CustomDomainUpdateInput, required: true }) + }, + resolve: async (query, _parent, args, context) => { + const { id, input } = args + + const customDomain = await prisma.customDomain.findUnique({ + where: { id }, + include: { team: { include: { userTeams: true } } } + }) + + if (customDomain == null) { + throw new GraphQLError('custom domain not found', { + extensions: { code: 'NOT_FOUND' } + }) + } + + if (!canAccessCustomDomain(Action.Update, customDomain, context.user)) { + throw new GraphQLError('user is not allowed to update custom domain', { + extensions: { code: 'FORBIDDEN' } + }) + } + + let journeyCollectionUpdate: + | { connect: { id: string } } + | { disconnect: true } + | undefined + + if ('journeyCollectionId' in input) { + if (input.journeyCollectionId == null) { + journeyCollectionUpdate = { disconnect: true } + } else { + const journeyCollection = await prisma.journeyCollection.findFirst({ + where: { + id: input.journeyCollectionId, + teamId: customDomain.teamId + } + }) + + if (journeyCollection == null) { + throw new GraphQLError( + 'journey collection not found for this custom domain team', + { extensions: { code: 'FORBIDDEN' } } + ) + } + + journeyCollectionUpdate = { + connect: { id: input.journeyCollectionId } + } + } + } + + return await prisma.customDomain.update({ + ...query, + where: { id }, + data: { + routeAllTeamJourneys: input.routeAllTeamJourneys ?? undefined, + journeyCollection: journeyCollectionUpdate + } + }) + } + }) +) diff --git a/apis/api-journeys-modern/src/schema/customDomain/customDomains.query.ts b/apis/api-journeys-modern/src/schema/customDomain/customDomains.query.ts index 42ef666a7cc..65bb7f6ea63 100644 --- a/apis/api-journeys-modern/src/schema/customDomain/customDomains.query.ts +++ b/apis/api-journeys-modern/src/schema/customDomain/customDomains.query.ts @@ -48,7 +48,6 @@ builder.queryField('customDomains', (t) => t.withAuth({ isAuthenticated: true }).prismaField({ type: [CustomDomainRef], nullable: false, - override: { from: 'api-journeys' }, args: { teamId: t.arg({ type: 'ID', required: true }) }, diff --git a/apis/api-journeys-modern/src/schema/customDomain/index.ts b/apis/api-journeys-modern/src/schema/customDomain/index.ts index e8a913a3446..1d7d8fb45db 100644 --- a/apis/api-journeys-modern/src/schema/customDomain/index.ts +++ b/apis/api-journeys-modern/src/schema/customDomain/index.ts @@ -1,4 +1,8 @@ import './customDomain' +import './customDomainCheck' +import './customDomainCheck.mutation' +import './customDomainDelete.mutation' +import './customDomainUpdate.mutation' import './customDomainCreate.mutation' import './customDomains.query' import './inputs' diff --git a/apis/api-journeys-modern/src/schema/customDomain/service.ts b/apis/api-journeys-modern/src/schema/customDomain/service.ts index a1dec6f0a29..d26a7b65e2b 100644 --- a/apis/api-journeys-modern/src/schema/customDomain/service.ts +++ b/apis/api-journeys-modern/src/schema/customDomain/service.ts @@ -19,6 +19,69 @@ interface VercelCreateDomainError { } } +interface VercelConfigDomainResponse { + configuredBy: string | null + nameservers: string[] + serviceType: string + cnames: string[] + aValues: string[] + conflicts: string[] + acceptedChallenges: string[] + misconfigured: boolean +} + +interface VercelDomainResponse { + name: string + apexName: string + projectId: string + redirect: null + redirectStatusCode: null + gitBranch: null + updatedAt: number + createdAt: number + verified: boolean + verification?: Array<{ + type: string + domain: string + value: string + reason: string + }> +} + +interface VercelVerifyDomainResponse { + name: string + apexName: string + projectId: string + redirect: null + redirectStatusCode: null + gitBranch: null + updatedAt: number + createdAt: number + verified: boolean +} + +interface VercelVerifyDomainError { + error: { + code: string + message: string + } +} + +export interface CustomDomainCheckResult { + configured: boolean + verified: boolean + verification?: Array<{ + type: string + domain: string + value: string + reason: string + }> | null + verificationResponse?: { + code: string + message: string + } | null +} + export function isDomainValid(domain: string): boolean { return DOMAIN_REGEX.test(domain) } @@ -175,3 +238,78 @@ export async function updateTeamShortLinks( }) } } + +export async function checkVercelDomain( + name: string +): Promise { + if (process.env.VERCEL_JOURNEYS_PROJECT_ID == null) + return { configured: true, verified: true } + + const [configResponse, domainResponse] = await Promise.all([ + fetch( + `https://api.vercel.com/v6/domains/${name}/config?teamId=${process.env.VERCEL_TEAM_ID}`, + { + headers: { Authorization: `Bearer ${process.env.VERCEL_TOKEN}` }, + method: 'GET' + } + ), + fetch( + `https://api.vercel.com/v9/projects/${process.env.VERCEL_JOURNEYS_PROJECT_ID}/domains/${name}?teamId=${process.env.VERCEL_TEAM_ID}`, + { + headers: { Authorization: `Bearer ${process.env.VERCEL_TOKEN}` }, + method: 'GET' + } + ) + ]) + + if (domainResponse.status !== 200) + throw new GraphQLError('vercel domain response not handled', { + extensions: { code: 'INTERNAL_SERVER_ERROR' } + }) + + if (configResponse.status !== 200) + throw new GraphQLError('vercel config response not handled', { + extensions: { code: 'INTERNAL_SERVER_ERROR' } + }) + + const configData: VercelConfigDomainResponse = await configResponse.json() + const domainData: VercelDomainResponse = await domainResponse.json() + + let verifyData: VercelVerifyDomainResponse | VercelVerifyDomainError | null = + null + + if (!domainData.verified) { + const verifyResponse = await fetch( + `https://api.vercel.com/v9/projects/${process.env.VERCEL_JOURNEYS_PROJECT_ID}/domains/${name}/verify?teamId=${process.env.VERCEL_TEAM_ID}`, + { + headers: { Authorization: `Bearer ${process.env.VERCEL_TOKEN}` }, + method: 'POST' + } + ) + + verifyData = await verifyResponse.json() + + if ( + verifyResponse.status !== 200 && + (verifyData == null || + ('error' in verifyData && + !['existing_project_domain', 'missing_txt_record'].includes( + verifyData.error.code + ))) + ) + throw new GraphQLError('vercel verification response not handled', { + extensions: { code: 'INTERNAL_SERVER_ERROR' } + }) + } + + if (verifyData != null && 'verified' in verifyData && verifyData.verified) + return { configured: !configData.misconfigured, verified: true } + + return { + configured: !configData.misconfigured, + verified: domainData.verified, + verification: domainData.verification ?? null, + verificationResponse: + verifyData != null && 'error' in verifyData ? verifyData.error : null + } +} diff --git a/apis/api-journeys-modern/src/schema/event/journey/index.ts b/apis/api-journeys-modern/src/schema/event/journey/index.ts index dc6fe07b13e..c6af9b214c9 100644 --- a/apis/api-journeys-modern/src/schema/event/journey/index.ts +++ b/apis/api-journeys-modern/src/schema/event/journey/index.ts @@ -1,3 +1,4 @@ import './journeyEvent' import './journeyViewEvent' +import './journeyViewEventCreate.mutation' import './inputs' diff --git a/apis/api-journeys-modern/src/schema/event/journey/journeyViewEventCreate.mutation.spec.ts b/apis/api-journeys-modern/src/schema/event/journey/journeyViewEventCreate.mutation.spec.ts new file mode 100644 index 00000000000..74f83de85df --- /dev/null +++ b/apis/api-journeys-modern/src/schema/event/journey/journeyViewEventCreate.mutation.spec.ts @@ -0,0 +1,263 @@ +import { getClient } from '../../../../test/client' +import { prismaMock } from '../../../../test/prismaMock' +import { graphql } from '../../../lib/graphql/subgraphGraphql' + +describe('journeyViewEventCreate', () => { + const mockUser = { id: 'userId' } + const authClient = getClient({ + headers: { authorization: 'token', 'user-agent': 'TestAgent/1.0' }, + context: { currentUser: mockUser } + }) + + const JOURNEY_VIEW_EVENT_CREATE = graphql(` + mutation JourneyViewEventCreate($input: JourneyViewEventCreateInput!) { + journeyViewEventCreate(input: $input) { + id + journeyId + label + value + } + } + `) + + beforeEach(() => { + prismaMock.journey.findUnique.mockResolvedValue({ + id: 'journeyId', + teamId: 'teamId' + } as any) + }) + + it('creates a JourneyViewEvent when no recent event exists', async () => { + prismaMock.visitor.findFirst.mockResolvedValue({ + id: 'visitorId', + userAgent: 'existing-agent' + } as any) + prismaMock.journeyVisitor.findUnique.mockResolvedValue({ + journeyId: 'journeyId', + visitorId: 'visitorId' + } as any) + prismaMock.event.findFirst.mockResolvedValue(null) + + const createdEvent = { + id: 'eventId', + typename: 'JourneyViewEvent', + journeyId: 'journeyId', + label: 'Journey Title', + value: '529', + visitorId: 'visitorId', + createdAt: new Date(), + languageId: null + } as any + + prismaMock.event.create.mockResolvedValue(createdEvent) + prismaMock.event.findMany.mockResolvedValue([createdEvent]) + prismaMock.event.findUnique.mockResolvedValue(createdEvent) + ;(prismaMock.event as any).findUniqueOrThrow?.mockResolvedValue?.( + createdEvent + ) + + const result = await authClient({ + document: JOURNEY_VIEW_EVENT_CREATE, + variables: { + input: { + id: 'eventId', + journeyId: 'journeyId', + label: 'Journey Title', + value: '529' + } + } + }) + + expect(result).toEqual({ + data: { + journeyViewEventCreate: expect.objectContaining({ + id: 'eventId', + journeyId: 'journeyId', + label: 'Journey Title', + value: '529' + }) + } + }) + + expect(prismaMock.event.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + id: 'eventId', + typename: 'JourneyViewEvent', + label: 'Journey Title', + value: '529', + visitor: { connect: { id: 'visitorId' } }, + journey: { connect: { id: 'journeyId' } } + }) + }) + ) + }) + + it('returns null when a recent JourneyViewEvent already exists', async () => { + prismaMock.visitor.findFirst.mockResolvedValue({ + id: 'visitorId', + userAgent: 'existing-agent' + } as any) + prismaMock.journeyVisitor.findUnique.mockResolvedValue({ + journeyId: 'journeyId', + visitorId: 'visitorId' + } as any) + prismaMock.event.findFirst.mockResolvedValue({ + id: 'existingEventId', + typename: 'JourneyViewEvent', + createdAt: new Date() + } as any) + + const result = await authClient({ + document: JOURNEY_VIEW_EVENT_CREATE, + variables: { + input: { + journeyId: 'journeyId' + } + } + }) + + expect(result).toEqual({ + data: { + journeyViewEventCreate: null + } + }) + + expect(prismaMock.event.create).not.toHaveBeenCalled() + }) + + it('throws NOT_FOUND when journey does not exist', async () => { + prismaMock.journey.findUnique.mockResolvedValue(null) + + const result = (await authClient({ + document: JOURNEY_VIEW_EVENT_CREATE, + variables: { + input: { + journeyId: 'nonExistentJourney' + } + } + })) as any + + expect(result.errors).toBeDefined() + expect(result.errors?.[0]?.message).toBe('Journey does not exist') + }) + + it('throws NOT_FOUND when visitor does not exist', async () => { + prismaMock.visitor.findFirst.mockResolvedValue(null) + + const result = (await authClient({ + document: JOURNEY_VIEW_EVENT_CREATE, + variables: { + input: { + journeyId: 'journeyId' + } + } + })) as any + + expect(result.errors).toBeDefined() + expect(result.errors?.[0]?.message).toBe('Visitor does not exist') + }) + + it('updates visitor userAgent when it is null', async () => { + prismaMock.visitor.findFirst.mockResolvedValue({ + id: 'visitorId', + userAgent: null + } as any) + prismaMock.journeyVisitor.findUnique.mockResolvedValue({ + journeyId: 'journeyId', + visitorId: 'visitorId' + } as any) + prismaMock.event.findFirst.mockResolvedValue(null) + + const createdEvent = { + id: 'eventId', + typename: 'JourneyViewEvent', + journeyId: 'journeyId', + label: null, + value: null, + visitorId: 'visitorId', + createdAt: new Date(), + languageId: null + } as any + + prismaMock.event.create.mockResolvedValue(createdEvent) + prismaMock.event.findMany.mockResolvedValue([createdEvent]) + prismaMock.event.findUnique.mockResolvedValue(createdEvent) + ;(prismaMock.event as any).findUniqueOrThrow?.mockResolvedValue?.( + createdEvent + ) + prismaMock.visitor.update.mockResolvedValue({ id: 'visitorId' } as any) + + await authClient({ + document: JOURNEY_VIEW_EVENT_CREATE, + variables: { + input: { + journeyId: 'journeyId' + } + } + }) + + expect(prismaMock.visitor.update).toHaveBeenCalledWith({ + where: { id: 'visitorId' }, + data: { userAgent: 'TestAgent/1.0' } + }) + }) + + it('handles optional fields (id, label, value)', async () => { + prismaMock.visitor.findFirst.mockResolvedValue({ + id: 'visitorId', + userAgent: 'existing-agent' + } as any) + prismaMock.journeyVisitor.findUnique.mockResolvedValue({ + journeyId: 'journeyId', + visitorId: 'visitorId' + } as any) + prismaMock.event.findFirst.mockResolvedValue(null) + + const createdEvent = { + id: 'auto-generated-id', + typename: 'JourneyViewEvent', + journeyId: 'journeyId', + label: null, + value: null, + visitorId: 'visitorId', + createdAt: new Date(), + languageId: null + } as any + + prismaMock.event.create.mockResolvedValue(createdEvent) + prismaMock.event.findMany.mockResolvedValue([createdEvent]) + prismaMock.event.findUnique.mockResolvedValue(createdEvent) + ;(prismaMock.event as any).findUniqueOrThrow?.mockResolvedValue?.( + createdEvent + ) + + const result = await authClient({ + document: JOURNEY_VIEW_EVENT_CREATE, + variables: { + input: { + journeyId: 'journeyId' + } + } + }) + + expect(result).toEqual({ + data: { + journeyViewEventCreate: expect.objectContaining({ + id: 'auto-generated-id', + journeyId: 'journeyId' + }) + } + }) + + expect(prismaMock.event.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + typename: 'JourneyViewEvent', + visitor: { connect: { id: 'visitorId' } }, + journey: { connect: { id: 'journeyId' } } + }) + }) + ) + }) +}) diff --git a/apis/api-journeys-modern/src/schema/event/journey/journeyViewEventCreate.mutation.ts b/apis/api-journeys-modern/src/schema/event/journey/journeyViewEventCreate.mutation.ts new file mode 100644 index 00000000000..e1a7e402bef --- /dev/null +++ b/apis/api-journeys-modern/src/schema/event/journey/journeyViewEventCreate.mutation.ts @@ -0,0 +1,89 @@ +import { GraphQLError } from 'graphql' + +import { prisma } from '@core/prisma/journeys/client' + +import { builder } from '../../builder' +import { ONE_DAY, getByUserIdAndJourneyId } from '../utils' + +import { JourneyViewEventCreateInput } from './inputs' +import { JourneyViewEventRef } from './journeyViewEvent' + +builder.mutationField('journeyViewEventCreate', (t) => + t.withAuth({ $any: { isAuthenticated: true, isAnonymous: true } }).field({ + type: JourneyViewEventRef, + nullable: true, + override: { from: 'api-journeys' }, + args: { + input: t.arg({ type: JourneyViewEventCreateInput, required: true }) + }, + resolve: async (_parent, args, context) => { + const { input } = args + const userId = context.user?.id + + const journey = await prisma.journey.findUnique({ + where: { id: input.journeyId } + }) + + if (journey == null) { + throw new GraphQLError('Journey does not exist', { + extensions: { code: 'NOT_FOUND' } + }) + } + + const visitorAndJourneyVisitor = await getByUserIdAndJourneyId( + userId, + input.journeyId + ) + + if (visitorAndJourneyVisitor == null) { + throw new GraphQLError('Visitor does not exist', { + extensions: { code: 'NOT_FOUND' } + }) + } + + const { visitor } = visitorAndJourneyVisitor + + const existingEvent = await prisma.event.findFirst({ + where: { + typename: 'JourneyViewEvent', + journeyId: input.journeyId, + visitorId: visitor.id, + createdAt: { + gte: new Date(Date.now() - ONE_DAY * 1000) + } + } + }) + + if (existingEvent != null) { + return null + } + + const userAgent = + (context as any).request?.headers?.get('user-agent') ?? null + + const event = prisma.event.create({ + data: { + ...(input.id != null ? { id: input.id } : {}), + typename: 'JourneyViewEvent', + label: input.label ?? undefined, + value: input.value ?? undefined, + visitor: { connect: { id: visitor.id } }, + journey: { connect: { id: input.journeyId } } + } + }) + + if (visitor.userAgent == null && userAgent != null) { + const [journeyViewEvent] = await Promise.all([ + event, + prisma.visitor.update({ + where: { id: visitor.id }, + data: { userAgent } + }) + ]) + return journeyViewEvent + } + + return await event + } + }) +) diff --git a/apis/api-journeys-modern/src/schema/event/step/index.ts b/apis/api-journeys-modern/src/schema/event/step/index.ts index c7265e0204e..b5c3a57888f 100644 --- a/apis/api-journeys-modern/src/schema/event/step/index.ts +++ b/apis/api-journeys-modern/src/schema/event/step/index.ts @@ -1,4 +1,5 @@ import './stepViewEvent' +import './stepViewEventCreate.mutation' import './stepNextEvent' import './stepPreviousEvent' import './inputs' diff --git a/apis/api-journeys-modern/src/schema/event/step/stepViewEventCreate.mutation.spec.ts b/apis/api-journeys-modern/src/schema/event/step/stepViewEventCreate.mutation.spec.ts new file mode 100644 index 00000000000..a8d86db90ce --- /dev/null +++ b/apis/api-journeys-modern/src/schema/event/step/stepViewEventCreate.mutation.spec.ts @@ -0,0 +1,271 @@ +import { getClient } from '../../../../test/client' +import { prismaMock } from '../../../../test/prismaMock' +import { graphql } from '../../../lib/graphql/subgraphGraphql' + +jest.mock('../utils', () => ({ + ...jest.requireActual('../utils'), + validateBlockEvent: jest.fn() +})) + +describe('stepViewEventCreate', () => { + const mockUser = { id: 'userId' } + const authClient = getClient({ + headers: { authorization: 'token' }, + context: { currentUser: mockUser } + }) + + const STEP_VIEW_EVENT_CREATE = graphql(` + mutation StepViewEventCreate($input: StepViewEventCreateInput!) { + stepViewEventCreate(input: $input) { + id + journeyId + value + } + } + `) + + const { validateBlockEvent } = require('../utils') + + const mockVisitor = { + id: 'visitorId', + createdAt: new Date('2024-01-01T00:00:00Z'), + userId: 'userId', + teamId: 'teamId' + } + + const mockJourneyVisitor = { + journeyId: 'journeyId', + visitorId: 'visitorId', + createdAt: new Date('2024-01-01T00:00:00Z') + } + + beforeEach(() => { + validateBlockEvent.mockResolvedValue({ + visitor: mockVisitor, + journeyVisitor: mockJourneyVisitor, + journeyId: 'journeyId', + teamId: 'teamId', + block: { id: 'blockId', journeyId: 'journeyId' } + }) + }) + + it('creates a StepViewEvent when authorized', async () => { + const createdEvent = { + id: 'eventId', + typename: 'StepViewEvent', + journeyId: 'journeyId', + label: null, + value: 'Step Title', + visitorId: 'visitorId', + createdAt: new Date(), + languageId: null, + stepId: 'blockId' + } as any + + prismaMock.event.create.mockResolvedValue(createdEvent) + prismaMock.event.findMany.mockResolvedValue([createdEvent]) + prismaMock.event.findUnique.mockResolvedValue(createdEvent) + ;(prismaMock.event as any).findUniqueOrThrow?.mockResolvedValue?.( + createdEvent + ) + prismaMock.visitor.update.mockResolvedValue(mockVisitor as any) + prismaMock.journeyVisitor.update.mockResolvedValue( + mockJourneyVisitor as any + ) + + const result = await authClient({ + document: STEP_VIEW_EVENT_CREATE, + variables: { + input: { + id: 'eventId', + blockId: 'blockId', + value: 'Step Title' + } + } + }) + + expect(result).toEqual({ + data: { + stepViewEventCreate: expect.objectContaining({ + id: 'eventId', + journeyId: 'journeyId', + value: 'Step Title' + }) + } + }) + + expect(prismaMock.event.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + id: 'eventId', + typename: 'StepViewEvent', + value: 'Step Title', + visitor: { connect: { id: 'visitorId' } }, + journey: { connect: { id: 'journeyId' } }, + stepId: 'blockId' + }) + }) + ) + + expect(prismaMock.visitor.update).toHaveBeenCalledWith({ + where: { id: 'visitorId' }, + data: { + duration: expect.any(Number), + lastStepViewedAt: expect.any(Date) + } + }) + + expect(prismaMock.journeyVisitor.update).toHaveBeenCalledWith({ + where: { + journeyId_visitorId: { + journeyId: 'journeyId', + visitorId: 'visitorId' + } + }, + data: { + duration: expect.any(Number), + lastStepViewedAt: expect.any(Date) + } + }) + }) + + it('returns NOT_FOUND when block does not exist', async () => { + const { GraphQLError } = require('graphql') + validateBlockEvent.mockRejectedValue( + new GraphQLError('Block does not exist', { + extensions: { code: 'NOT_FOUND' } + }) + ) + + const result = (await authClient({ + document: STEP_VIEW_EVENT_CREATE, + variables: { + input: { + blockId: 'nonExistentBlock', + value: 'Step Title' + } + } + })) as any + + expect(result.errors).toBeDefined() + expect(result.errors?.[0]?.message).toBe('Block does not exist') + }) + + it('creates event without optional id and value', async () => { + const createdEvent = { + id: 'auto-generated-id', + typename: 'StepViewEvent', + journeyId: 'journeyId', + label: null, + value: null, + visitorId: 'visitorId', + createdAt: new Date(), + languageId: null, + stepId: 'blockId' + } as any + + prismaMock.event.create.mockResolvedValue(createdEvent) + prismaMock.event.findMany.mockResolvedValue([createdEvent]) + prismaMock.event.findUnique.mockResolvedValue(createdEvent) + ;(prismaMock.event as any).findUniqueOrThrow?.mockResolvedValue?.( + createdEvent + ) + prismaMock.visitor.update.mockResolvedValue(mockVisitor as any) + prismaMock.journeyVisitor.update.mockResolvedValue( + mockJourneyVisitor as any + ) + + const result = await authClient({ + document: STEP_VIEW_EVENT_CREATE, + variables: { + input: { + blockId: 'blockId' + } + } + }) + + expect(result).toEqual({ + data: { + stepViewEventCreate: expect.objectContaining({ + id: 'auto-generated-id', + journeyId: 'journeyId', + value: null + }) + } + }) + + expect(prismaMock.event.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + typename: 'StepViewEvent', + visitor: { connect: { id: 'visitorId' } }, + journey: { connect: { id: 'journeyId' } } + }) + }) + ) + }) + + it('caps duration at 1200 seconds', async () => { + const oldDate = new Date('2020-01-01T00:00:00Z') + validateBlockEvent.mockResolvedValue({ + visitor: { ...mockVisitor, createdAt: oldDate }, + journeyVisitor: { ...mockJourneyVisitor, createdAt: oldDate }, + journeyId: 'journeyId', + teamId: 'teamId', + block: { id: 'blockId', journeyId: 'journeyId' } + }) + + const createdEvent = { + id: 'eventId', + typename: 'StepViewEvent', + journeyId: 'journeyId', + label: null, + value: null, + visitorId: 'visitorId', + createdAt: new Date(), + languageId: null, + stepId: 'blockId' + } as any + + prismaMock.event.create.mockResolvedValue(createdEvent) + prismaMock.event.findMany.mockResolvedValue([createdEvent]) + prismaMock.event.findUnique.mockResolvedValue(createdEvent) + ;(prismaMock.event as any).findUniqueOrThrow?.mockResolvedValue?.( + createdEvent + ) + prismaMock.visitor.update.mockResolvedValue(mockVisitor as any) + prismaMock.journeyVisitor.update.mockResolvedValue( + mockJourneyVisitor as any + ) + + await authClient({ + document: STEP_VIEW_EVENT_CREATE, + variables: { + input: { + blockId: 'blockId' + } + } + }) + + expect(prismaMock.visitor.update).toHaveBeenCalledWith({ + where: { id: 'visitorId' }, + data: { + duration: 1200, + lastStepViewedAt: expect.any(Date) + } + }) + + expect(prismaMock.journeyVisitor.update).toHaveBeenCalledWith({ + where: { + journeyId_visitorId: { + journeyId: 'journeyId', + visitorId: 'visitorId' + } + }, + data: { + duration: 1200, + lastStepViewedAt: expect.any(Date) + } + }) + }) +}) diff --git a/apis/api-journeys-modern/src/schema/event/step/stepViewEventCreate.mutation.ts b/apis/api-journeys-modern/src/schema/event/step/stepViewEventCreate.mutation.ts new file mode 100644 index 00000000000..af7d5eda938 --- /dev/null +++ b/apis/api-journeys-modern/src/schema/event/step/stepViewEventCreate.mutation.ts @@ -0,0 +1,79 @@ +import { prisma } from '@core/prisma/journeys/client' + +import { builder } from '../../builder' +import { validateBlockEvent } from '../utils' + +import { StepViewEventCreateInput } from './inputs' +import { StepViewEventRef } from './stepViewEvent' + +builder.mutationField('stepViewEventCreate', (t) => + t.withAuth({ $any: { isAuthenticated: true, isAnonymous: true } }).field({ + type: StepViewEventRef, + nullable: false, + override: { from: 'api-journeys' }, + args: { + input: t.arg({ type: StepViewEventCreateInput, required: true }) + }, + resolve: async (_parent, args, context) => { + const { input } = args + const userId = context.user?.id + + if (userId == null) { + throw new Error('User not authenticated') + } + + const { visitor, journeyVisitor, journeyId } = await validateBlockEvent( + userId, + input.blockId, + input.blockId + ) + + const [stepViewEvent] = await Promise.all([ + prisma.event.create({ + data: { + ...(input.id != null ? { id: input.id } : {}), + typename: 'StepViewEvent', + value: input.value ?? undefined, + visitor: { connect: { id: visitor.id } }, + journey: { connect: { id: journeyId } }, + stepId: input.blockId ?? undefined + } + }), + prisma.visitor.update({ + where: { id: visitor.id }, + data: { + duration: Math.min( + 1200, + Math.floor( + Math.abs(Date.now() - new Date(visitor.createdAt).getTime()) / + 1000 + ) + ), + lastStepViewedAt: new Date() + } + }), + prisma.journeyVisitor.update({ + where: { + journeyId_visitorId: { + journeyId, + visitorId: visitor.id + } + }, + data: { + duration: Math.min( + 1200, + Math.floor( + Math.abs( + Date.now() - new Date(journeyVisitor.createdAt).getTime() + ) / 1000 + ) + ), + lastStepViewedAt: new Date() + } + }) + ]) + + return stepViewEvent + } + }) +) diff --git a/apis/api-journeys-modern/src/schema/event/video/index.ts b/apis/api-journeys-modern/src/schema/event/video/index.ts index 7fce87a5573..1ff36232712 100644 --- a/apis/api-journeys-modern/src/schema/event/video/index.ts +++ b/apis/api-journeys-modern/src/schema/event/video/index.ts @@ -1,8 +1,15 @@ import './videoStartEvent' +import './videoStartEventCreate.mutation' import './videoPlayEvent' +import './videoPlayEventCreate.mutation' import './videoPauseEvent' +import './videoPauseEventCreate.mutation' import './videoCompleteEvent' +import './videoCompleteEventCreate.mutation' import './videoExpandEvent' +import './videoExpandEventCreate.mutation' import './videoCollapseEvent' +import './videoCollapseEventCreate.mutation' import './videoProgressEvent' +import './videoProgressEventCreate.mutation' import './inputs' diff --git a/apis/api-journeys-modern/src/schema/event/video/videoCollapseEventCreate.mutation.spec.ts b/apis/api-journeys-modern/src/schema/event/video/videoCollapseEventCreate.mutation.spec.ts new file mode 100644 index 00000000000..803f12c0184 --- /dev/null +++ b/apis/api-journeys-modern/src/schema/event/video/videoCollapseEventCreate.mutation.spec.ts @@ -0,0 +1,165 @@ +import { getClient } from '../../../../test/client' +import { prismaMock } from '../../../../test/prismaMock' +import { graphql } from '../../../lib/graphql/subgraphGraphql' + +jest.mock('../utils', () => ({ + ...jest.requireActual('../utils'), + validateBlockEvent: jest.fn() +})) + +describe('videoCollapseEventCreate', () => { + const mockUser = { id: 'userId' } + const authClient = getClient({ + headers: { authorization: 'token' }, + context: { currentUser: mockUser } + }) + + const VIDEO_COLLAPSE_EVENT_CREATE = graphql(` + mutation VideoCollapseEventCreate($input: VideoCollapseEventCreateInput!) { + videoCollapseEventCreate(input: $input) { + id + journeyId + } + } + `) + + const { validateBlockEvent } = require('../utils') + + const mockVisitor = { + id: 'visitorId', + createdAt: new Date('2024-01-01T00:00:00Z'), + userId: 'userId', + teamId: 'teamId' + } + + beforeEach(() => { + validateBlockEvent.mockResolvedValue({ + visitor: mockVisitor, + journeyVisitor: { + journeyId: 'journeyId', + visitorId: 'visitorId' + }, + journeyId: 'journeyId', + teamId: 'teamId', + block: { id: 'blockId', journeyId: 'journeyId' } + }) + }) + + it('creates a VideoCollapseEvent when authorized', async () => { + const createdEvent = { + id: 'eventId', + typename: 'VideoCollapseEvent', + journeyId: 'journeyId', + visitorId: 'visitorId', + createdAt: new Date(), + position: null, + source: null + } as any + + prismaMock.event.create.mockResolvedValue(createdEvent) + prismaMock.event.findMany.mockResolvedValue([createdEvent]) + prismaMock.event.findUnique.mockResolvedValue(createdEvent) + ;(prismaMock.event as any).findUniqueOrThrow?.mockResolvedValue?.( + createdEvent + ) + + const result = await authClient({ + document: VIDEO_COLLAPSE_EVENT_CREATE, + variables: { + input: { + id: 'eventId', + blockId: 'blockId', + stepId: 'stepId' + } + } + }) + + expect(result).toEqual({ + data: { + videoCollapseEventCreate: expect.objectContaining({ + id: 'eventId', + journeyId: 'journeyId' + }) + } + }) + + expect(prismaMock.event.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + id: 'eventId', + typename: 'VideoCollapseEvent', + blockId: 'blockId', + visitor: { connect: { id: 'visitorId' } }, + journey: { connect: { id: 'journeyId' } }, + stepId: 'stepId' + }) + }) + ) + }) + + it('returns error when block does not exist', async () => { + const { GraphQLError } = require('graphql') + validateBlockEvent.mockRejectedValue( + new GraphQLError('Block does not exist', { + extensions: { code: 'NOT_FOUND' } + }) + ) + + const result = (await authClient({ + document: VIDEO_COLLAPSE_EVENT_CREATE, + variables: { + input: { + blockId: 'nonExistentBlock' + } + } + })) as any + + expect(result.errors).toBeDefined() + expect(result.errors?.[0]?.message).toBe('Block does not exist') + }) + + it('creates event without optional fields', async () => { + const createdEvent = { + id: 'auto-id', + typename: 'VideoCollapseEvent', + journeyId: 'journeyId', + visitorId: 'visitorId', + createdAt: new Date() + } as any + + prismaMock.event.create.mockResolvedValue(createdEvent) + prismaMock.event.findMany.mockResolvedValue([createdEvent]) + prismaMock.event.findUnique.mockResolvedValue(createdEvent) + ;(prismaMock.event as any).findUniqueOrThrow?.mockResolvedValue?.( + createdEvent + ) + + const result = await authClient({ + document: VIDEO_COLLAPSE_EVENT_CREATE, + variables: { + input: { + blockId: 'blockId' + } + } + }) + + expect(result).toEqual({ + data: { + videoCollapseEventCreate: expect.objectContaining({ + id: 'auto-id', + journeyId: 'journeyId' + }) + } + }) + + expect(prismaMock.event.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + typename: 'VideoCollapseEvent', + visitor: { connect: { id: 'visitorId' } }, + journey: { connect: { id: 'journeyId' } } + }) + }) + ) + }) +}) diff --git a/apis/api-journeys-modern/src/schema/event/video/videoCollapseEventCreate.mutation.ts b/apis/api-journeys-modern/src/schema/event/video/videoCollapseEventCreate.mutation.ts new file mode 100644 index 00000000000..a7d799c2332 --- /dev/null +++ b/apis/api-journeys-modern/src/schema/event/video/videoCollapseEventCreate.mutation.ts @@ -0,0 +1,46 @@ +import { prisma } from '@core/prisma/journeys/client' + +import { builder } from '../../builder' +import { validateBlockEvent } from '../utils' + +import { VideoCollapseEventCreateInput } from './inputs' +import { VideoCollapseEventRef } from './videoCollapseEvent' + +builder.mutationField('videoCollapseEventCreate', (t) => + t.withAuth({ $any: { isAuthenticated: true, isAnonymous: true } }).field({ + type: VideoCollapseEventRef, + nullable: false, + override: { from: 'api-journeys' }, + args: { + input: t.arg({ type: VideoCollapseEventCreateInput, required: true }) + }, + resolve: async (_parent, args, context) => { + const { input } = args + const userId = context.user?.id + + if (userId == null) { + throw new Error('User not authenticated') + } + + const { visitor, journeyId } = await validateBlockEvent( + userId, + input.blockId, + input.stepId + ) + + return await prisma.event.create({ + data: { + ...(input.id != null ? { id: input.id } : {}), + typename: 'VideoCollapseEvent', + blockId: input.blockId, + label: input.label ?? undefined, + value: input.value ?? undefined, + position: input.position ?? undefined, + visitor: { connect: { id: visitor.id } }, + journey: { connect: { id: journeyId } }, + stepId: input.stepId ?? undefined + } + }) + } + }) +) diff --git a/apis/api-journeys-modern/src/schema/event/video/videoCompleteEventCreate.mutation.spec.ts b/apis/api-journeys-modern/src/schema/event/video/videoCompleteEventCreate.mutation.spec.ts new file mode 100644 index 00000000000..43700ebc07e --- /dev/null +++ b/apis/api-journeys-modern/src/schema/event/video/videoCompleteEventCreate.mutation.spec.ts @@ -0,0 +1,172 @@ +import { getClient } from '../../../../test/client' +import { prismaMock } from '../../../../test/prismaMock' +import { graphql } from '../../../lib/graphql/subgraphGraphql' + +jest.mock('../utils', () => ({ + ...jest.requireActual('../utils'), + validateBlockEvent: jest.fn(), + resetEventsEmailDelay: jest.fn() +})) + +describe('videoCompleteEventCreate', () => { + const mockUser = { id: 'userId' } + const authClient = getClient({ + headers: { authorization: 'token' }, + context: { currentUser: mockUser } + }) + + const VIDEO_COMPLETE_EVENT_CREATE = graphql(` + mutation VideoCompleteEventCreate($input: VideoCompleteEventCreateInput!) { + videoCompleteEventCreate(input: $input) { + id + journeyId + } + } + `) + + const { validateBlockEvent, resetEventsEmailDelay } = require('../utils') + + const mockVisitor = { + id: 'visitorId', + createdAt: new Date('2024-01-01T00:00:00Z'), + userId: 'userId', + teamId: 'teamId' + } + + beforeEach(() => { + validateBlockEvent.mockResolvedValue({ + visitor: mockVisitor, + journeyVisitor: { + journeyId: 'journeyId', + visitorId: 'visitorId' + }, + journeyId: 'journeyId', + teamId: 'teamId', + block: { id: 'blockId', journeyId: 'journeyId' } + }) + resetEventsEmailDelay.mockResolvedValue(undefined) + }) + + it('creates a VideoCompleteEvent when authorized', async () => { + const createdEvent = { + id: 'eventId', + typename: 'VideoCompleteEvent', + journeyId: 'journeyId', + visitorId: 'visitorId', + createdAt: new Date(), + position: null, + source: null + } as any + + prismaMock.event.create.mockResolvedValue(createdEvent) + prismaMock.event.findMany.mockResolvedValue([createdEvent]) + prismaMock.event.findUnique.mockResolvedValue(createdEvent) + ;(prismaMock.event as any).findUniqueOrThrow?.mockResolvedValue?.( + createdEvent + ) + + const result = await authClient({ + document: VIDEO_COMPLETE_EVENT_CREATE, + variables: { + input: { + id: 'eventId', + blockId: 'blockId', + stepId: 'stepId' + } + } + }) + + expect(result).toEqual({ + data: { + videoCompleteEventCreate: expect.objectContaining({ + id: 'eventId', + journeyId: 'journeyId' + }) + } + }) + + expect(prismaMock.event.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + id: 'eventId', + typename: 'VideoCompleteEvent', + blockId: 'blockId', + visitor: { connect: { id: 'visitorId' } }, + journey: { connect: { id: 'journeyId' } }, + stepId: 'stepId' + }) + }) + ) + + expect(resetEventsEmailDelay).toHaveBeenCalledWith( + 'journeyId', + 'visitorId' + ) + }) + + it('returns error when block does not exist', async () => { + const { GraphQLError } = require('graphql') + validateBlockEvent.mockRejectedValue( + new GraphQLError('Block does not exist', { + extensions: { code: 'NOT_FOUND' } + }) + ) + + const result = (await authClient({ + document: VIDEO_COMPLETE_EVENT_CREATE, + variables: { + input: { + blockId: 'nonExistentBlock' + } + } + })) as any + + expect(result.errors).toBeDefined() + expect(result.errors?.[0]?.message).toBe('Block does not exist') + }) + + it('creates event without optional fields', async () => { + const createdEvent = { + id: 'auto-id', + typename: 'VideoCompleteEvent', + journeyId: 'journeyId', + visitorId: 'visitorId', + createdAt: new Date() + } as any + + prismaMock.event.create.mockResolvedValue(createdEvent) + prismaMock.event.findMany.mockResolvedValue([createdEvent]) + prismaMock.event.findUnique.mockResolvedValue(createdEvent) + ;(prismaMock.event as any).findUniqueOrThrow?.mockResolvedValue?.( + createdEvent + ) + + const result = await authClient({ + document: VIDEO_COMPLETE_EVENT_CREATE, + variables: { + input: { + blockId: 'blockId' + } + } + }) + + expect(result).toEqual({ + data: { + videoCompleteEventCreate: expect.objectContaining({ + id: 'auto-id', + journeyId: 'journeyId' + }) + } + }) + + expect(prismaMock.event.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + typename: 'VideoCompleteEvent', + visitor: { connect: { id: 'visitorId' } }, + journey: { connect: { id: 'journeyId' } } + }) + }) + ) + }) +}) diff --git a/apis/api-journeys-modern/src/schema/event/video/videoCompleteEventCreate.mutation.ts b/apis/api-journeys-modern/src/schema/event/video/videoCompleteEventCreate.mutation.ts new file mode 100644 index 00000000000..d8312ce5297 --- /dev/null +++ b/apis/api-journeys-modern/src/schema/event/video/videoCompleteEventCreate.mutation.ts @@ -0,0 +1,48 @@ +import { prisma } from '@core/prisma/journeys/client' + +import { builder } from '../../builder' +import { resetEventsEmailDelay, validateBlockEvent } from '../utils' + +import { VideoCompleteEventCreateInput } from './inputs' +import { VideoCompleteEventRef } from './videoCompleteEvent' + +builder.mutationField('videoCompleteEventCreate', (t) => + t.withAuth({ $any: { isAuthenticated: true, isAnonymous: true } }).field({ + type: VideoCompleteEventRef, + nullable: false, + override: { from: 'api-journeys' }, + args: { + input: t.arg({ type: VideoCompleteEventCreateInput, required: true }) + }, + resolve: async (_parent, args, context) => { + const { input } = args + const userId = context.user?.id + + if (userId == null) { + throw new Error('User not authenticated') + } + + const { visitor, journeyId } = await validateBlockEvent( + userId, + input.blockId, + input.stepId + ) + + await resetEventsEmailDelay(journeyId, visitor.id) + + return await prisma.event.create({ + data: { + ...(input.id != null ? { id: input.id } : {}), + typename: 'VideoCompleteEvent', + blockId: input.blockId, + label: input.label ?? undefined, + value: input.value ?? undefined, + position: input.position ?? undefined, + visitor: { connect: { id: visitor.id } }, + journey: { connect: { id: journeyId } }, + stepId: input.stepId ?? undefined + } + }) + } + }) +) diff --git a/apis/api-journeys-modern/src/schema/event/video/videoExpandEventCreate.mutation.spec.ts b/apis/api-journeys-modern/src/schema/event/video/videoExpandEventCreate.mutation.spec.ts new file mode 100644 index 00000000000..4fb5d4649e4 --- /dev/null +++ b/apis/api-journeys-modern/src/schema/event/video/videoExpandEventCreate.mutation.spec.ts @@ -0,0 +1,165 @@ +import { getClient } from '../../../../test/client' +import { prismaMock } from '../../../../test/prismaMock' +import { graphql } from '../../../lib/graphql/subgraphGraphql' + +jest.mock('../utils', () => ({ + ...jest.requireActual('../utils'), + validateBlockEvent: jest.fn() +})) + +describe('videoExpandEventCreate', () => { + const mockUser = { id: 'userId' } + const authClient = getClient({ + headers: { authorization: 'token' }, + context: { currentUser: mockUser } + }) + + const VIDEO_EXPAND_EVENT_CREATE = graphql(` + mutation VideoExpandEventCreate($input: VideoExpandEventCreateInput!) { + videoExpandEventCreate(input: $input) { + id + journeyId + } + } + `) + + const { validateBlockEvent } = require('../utils') + + const mockVisitor = { + id: 'visitorId', + createdAt: new Date('2024-01-01T00:00:00Z'), + userId: 'userId', + teamId: 'teamId' + } + + beforeEach(() => { + validateBlockEvent.mockResolvedValue({ + visitor: mockVisitor, + journeyVisitor: { + journeyId: 'journeyId', + visitorId: 'visitorId' + }, + journeyId: 'journeyId', + teamId: 'teamId', + block: { id: 'blockId', journeyId: 'journeyId' } + }) + }) + + it('creates a VideoExpandEvent when authorized', async () => { + const createdEvent = { + id: 'eventId', + typename: 'VideoExpandEvent', + journeyId: 'journeyId', + visitorId: 'visitorId', + createdAt: new Date(), + position: null, + source: null + } as any + + prismaMock.event.create.mockResolvedValue(createdEvent) + prismaMock.event.findMany.mockResolvedValue([createdEvent]) + prismaMock.event.findUnique.mockResolvedValue(createdEvent) + ;(prismaMock.event as any).findUniqueOrThrow?.mockResolvedValue?.( + createdEvent + ) + + const result = await authClient({ + document: VIDEO_EXPAND_EVENT_CREATE, + variables: { + input: { + id: 'eventId', + blockId: 'blockId', + stepId: 'stepId' + } + } + }) + + expect(result).toEqual({ + data: { + videoExpandEventCreate: expect.objectContaining({ + id: 'eventId', + journeyId: 'journeyId' + }) + } + }) + + expect(prismaMock.event.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + id: 'eventId', + typename: 'VideoExpandEvent', + blockId: 'blockId', + visitor: { connect: { id: 'visitorId' } }, + journey: { connect: { id: 'journeyId' } }, + stepId: 'stepId' + }) + }) + ) + }) + + it('returns error when block does not exist', async () => { + const { GraphQLError } = require('graphql') + validateBlockEvent.mockRejectedValue( + new GraphQLError('Block does not exist', { + extensions: { code: 'NOT_FOUND' } + }) + ) + + const result = (await authClient({ + document: VIDEO_EXPAND_EVENT_CREATE, + variables: { + input: { + blockId: 'nonExistentBlock' + } + } + })) as any + + expect(result.errors).toBeDefined() + expect(result.errors?.[0]?.message).toBe('Block does not exist') + }) + + it('creates event without optional fields', async () => { + const createdEvent = { + id: 'auto-id', + typename: 'VideoExpandEvent', + journeyId: 'journeyId', + visitorId: 'visitorId', + createdAt: new Date() + } as any + + prismaMock.event.create.mockResolvedValue(createdEvent) + prismaMock.event.findMany.mockResolvedValue([createdEvent]) + prismaMock.event.findUnique.mockResolvedValue(createdEvent) + ;(prismaMock.event as any).findUniqueOrThrow?.mockResolvedValue?.( + createdEvent + ) + + const result = await authClient({ + document: VIDEO_EXPAND_EVENT_CREATE, + variables: { + input: { + blockId: 'blockId' + } + } + }) + + expect(result).toEqual({ + data: { + videoExpandEventCreate: expect.objectContaining({ + id: 'auto-id', + journeyId: 'journeyId' + }) + } + }) + + expect(prismaMock.event.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + typename: 'VideoExpandEvent', + visitor: { connect: { id: 'visitorId' } }, + journey: { connect: { id: 'journeyId' } } + }) + }) + ) + }) +}) diff --git a/apis/api-journeys-modern/src/schema/event/video/videoExpandEventCreate.mutation.ts b/apis/api-journeys-modern/src/schema/event/video/videoExpandEventCreate.mutation.ts new file mode 100644 index 00000000000..154b672426c --- /dev/null +++ b/apis/api-journeys-modern/src/schema/event/video/videoExpandEventCreate.mutation.ts @@ -0,0 +1,46 @@ +import { prisma } from '@core/prisma/journeys/client' + +import { builder } from '../../builder' +import { validateBlockEvent } from '../utils' + +import { VideoExpandEventCreateInput } from './inputs' +import { VideoExpandEventRef } from './videoExpandEvent' + +builder.mutationField('videoExpandEventCreate', (t) => + t.withAuth({ $any: { isAuthenticated: true, isAnonymous: true } }).field({ + type: VideoExpandEventRef, + nullable: false, + override: { from: 'api-journeys' }, + args: { + input: t.arg({ type: VideoExpandEventCreateInput, required: true }) + }, + resolve: async (_parent, args, context) => { + const { input } = args + const userId = context.user?.id + + if (userId == null) { + throw new Error('User not authenticated') + } + + const { visitor, journeyId } = await validateBlockEvent( + userId, + input.blockId, + input.stepId + ) + + return await prisma.event.create({ + data: { + ...(input.id != null ? { id: input.id } : {}), + typename: 'VideoExpandEvent', + blockId: input.blockId, + label: input.label ?? undefined, + value: input.value ?? undefined, + position: input.position ?? undefined, + visitor: { connect: { id: visitor.id } }, + journey: { connect: { id: journeyId } }, + stepId: input.stepId ?? undefined + } + }) + } + }) +) diff --git a/apis/api-journeys-modern/src/schema/event/video/videoPauseEventCreate.mutation.spec.ts b/apis/api-journeys-modern/src/schema/event/video/videoPauseEventCreate.mutation.spec.ts new file mode 100644 index 00000000000..c92bf55ee2a --- /dev/null +++ b/apis/api-journeys-modern/src/schema/event/video/videoPauseEventCreate.mutation.spec.ts @@ -0,0 +1,165 @@ +import { getClient } from '../../../../test/client' +import { prismaMock } from '../../../../test/prismaMock' +import { graphql } from '../../../lib/graphql/subgraphGraphql' + +jest.mock('../utils', () => ({ + ...jest.requireActual('../utils'), + validateBlockEvent: jest.fn() +})) + +describe('videoPauseEventCreate', () => { + const mockUser = { id: 'userId' } + const authClient = getClient({ + headers: { authorization: 'token' }, + context: { currentUser: mockUser } + }) + + const VIDEO_PAUSE_EVENT_CREATE = graphql(` + mutation VideoPauseEventCreate($input: VideoPauseEventCreateInput!) { + videoPauseEventCreate(input: $input) { + id + journeyId + } + } + `) + + const { validateBlockEvent } = require('../utils') + + const mockVisitor = { + id: 'visitorId', + createdAt: new Date('2024-01-01T00:00:00Z'), + userId: 'userId', + teamId: 'teamId' + } + + beforeEach(() => { + validateBlockEvent.mockResolvedValue({ + visitor: mockVisitor, + journeyVisitor: { + journeyId: 'journeyId', + visitorId: 'visitorId' + }, + journeyId: 'journeyId', + teamId: 'teamId', + block: { id: 'blockId', journeyId: 'journeyId' } + }) + }) + + it('creates a VideoPauseEvent when authorized', async () => { + const createdEvent = { + id: 'eventId', + typename: 'VideoPauseEvent', + journeyId: 'journeyId', + visitorId: 'visitorId', + createdAt: new Date(), + position: null, + source: null + } as any + + prismaMock.event.create.mockResolvedValue(createdEvent) + prismaMock.event.findMany.mockResolvedValue([createdEvent]) + prismaMock.event.findUnique.mockResolvedValue(createdEvent) + ;(prismaMock.event as any).findUniqueOrThrow?.mockResolvedValue?.( + createdEvent + ) + + const result = await authClient({ + document: VIDEO_PAUSE_EVENT_CREATE, + variables: { + input: { + id: 'eventId', + blockId: 'blockId', + stepId: 'stepId' + } + } + }) + + expect(result).toEqual({ + data: { + videoPauseEventCreate: expect.objectContaining({ + id: 'eventId', + journeyId: 'journeyId' + }) + } + }) + + expect(prismaMock.event.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + id: 'eventId', + typename: 'VideoPauseEvent', + blockId: 'blockId', + visitor: { connect: { id: 'visitorId' } }, + journey: { connect: { id: 'journeyId' } }, + stepId: 'stepId' + }) + }) + ) + }) + + it('returns error when block does not exist', async () => { + const { GraphQLError } = require('graphql') + validateBlockEvent.mockRejectedValue( + new GraphQLError('Block does not exist', { + extensions: { code: 'NOT_FOUND' } + }) + ) + + const result = (await authClient({ + document: VIDEO_PAUSE_EVENT_CREATE, + variables: { + input: { + blockId: 'nonExistentBlock' + } + } + })) as any + + expect(result.errors).toBeDefined() + expect(result.errors?.[0]?.message).toBe('Block does not exist') + }) + + it('creates event without optional fields', async () => { + const createdEvent = { + id: 'auto-id', + typename: 'VideoPauseEvent', + journeyId: 'journeyId', + visitorId: 'visitorId', + createdAt: new Date() + } as any + + prismaMock.event.create.mockResolvedValue(createdEvent) + prismaMock.event.findMany.mockResolvedValue([createdEvent]) + prismaMock.event.findUnique.mockResolvedValue(createdEvent) + ;(prismaMock.event as any).findUniqueOrThrow?.mockResolvedValue?.( + createdEvent + ) + + const result = await authClient({ + document: VIDEO_PAUSE_EVENT_CREATE, + variables: { + input: { + blockId: 'blockId' + } + } + }) + + expect(result).toEqual({ + data: { + videoPauseEventCreate: expect.objectContaining({ + id: 'auto-id', + journeyId: 'journeyId' + }) + } + }) + + expect(prismaMock.event.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + typename: 'VideoPauseEvent', + visitor: { connect: { id: 'visitorId' } }, + journey: { connect: { id: 'journeyId' } } + }) + }) + ) + }) +}) diff --git a/apis/api-journeys-modern/src/schema/event/video/videoPauseEventCreate.mutation.ts b/apis/api-journeys-modern/src/schema/event/video/videoPauseEventCreate.mutation.ts new file mode 100644 index 00000000000..238cb7afde6 --- /dev/null +++ b/apis/api-journeys-modern/src/schema/event/video/videoPauseEventCreate.mutation.ts @@ -0,0 +1,46 @@ +import { prisma } from '@core/prisma/journeys/client' + +import { builder } from '../../builder' +import { validateBlockEvent } from '../utils' + +import { VideoPauseEventCreateInput } from './inputs' +import { VideoPauseEventRef } from './videoPauseEvent' + +builder.mutationField('videoPauseEventCreate', (t) => + t.withAuth({ $any: { isAuthenticated: true, isAnonymous: true } }).field({ + type: VideoPauseEventRef, + nullable: false, + override: { from: 'api-journeys' }, + args: { + input: t.arg({ type: VideoPauseEventCreateInput, required: true }) + }, + resolve: async (_parent, args, context) => { + const { input } = args + const userId = context.user?.id + + if (userId == null) { + throw new Error('User not authenticated') + } + + const { visitor, journeyId } = await validateBlockEvent( + userId, + input.blockId, + input.stepId + ) + + return await prisma.event.create({ + data: { + ...(input.id != null ? { id: input.id } : {}), + typename: 'VideoPauseEvent', + blockId: input.blockId, + label: input.label ?? undefined, + value: input.value ?? undefined, + position: input.position ?? undefined, + visitor: { connect: { id: visitor.id } }, + journey: { connect: { id: journeyId } }, + stepId: input.stepId ?? undefined + } + }) + } + }) +) diff --git a/apis/api-journeys-modern/src/schema/event/video/videoPlayEventCreate.mutation.spec.ts b/apis/api-journeys-modern/src/schema/event/video/videoPlayEventCreate.mutation.spec.ts new file mode 100644 index 00000000000..365dea9ac18 --- /dev/null +++ b/apis/api-journeys-modern/src/schema/event/video/videoPlayEventCreate.mutation.spec.ts @@ -0,0 +1,260 @@ +import { getClient } from '../../../../test/client' +import { prismaMock } from '../../../../test/prismaMock' +import { graphql } from '../../../lib/graphql/subgraphGraphql' + +jest.mock('../utils', () => ({ + ...jest.requireActual('../utils'), + validateBlockEvent: jest.fn(), + resetEventsEmailDelay: jest.fn() +})) + +describe('videoPlayEventCreate', () => { + const mockUser = { id: 'userId' } + const authClient = getClient({ + headers: { authorization: 'token' }, + context: { currentUser: mockUser } + }) + + const VIDEO_PLAY_EVENT_CREATE = graphql(` + mutation VideoPlayEventCreate($input: VideoPlayEventCreateInput!) { + videoPlayEventCreate(input: $input) { + id + journeyId + } + } + `) + + const { validateBlockEvent, resetEventsEmailDelay } = require('../utils') + + const mockVisitor = { + id: 'visitorId', + createdAt: new Date('2024-01-01T00:00:00Z'), + userId: 'userId', + teamId: 'teamId' + } + + beforeEach(() => { + validateBlockEvent.mockResolvedValue({ + visitor: mockVisitor, + journeyVisitor: { + journeyId: 'journeyId', + visitorId: 'visitorId' + }, + journeyId: 'journeyId', + teamId: 'teamId', + block: { id: 'blockId', journeyId: 'journeyId' } + }) + resetEventsEmailDelay.mockResolvedValue(undefined) + }) + + it('creates a VideoPlayEvent when authorized', async () => { + prismaMock.block.findUnique.mockResolvedValue({ + id: 'blockId', + duration: 120, + startAt: 10, + endAt: 50 + } as any) + + const createdEvent = { + id: 'eventId', + typename: 'VideoPlayEvent', + journeyId: 'journeyId', + visitorId: 'visitorId', + createdAt: new Date(), + position: null, + source: null + } as any + + prismaMock.event.create.mockResolvedValue(createdEvent) + prismaMock.event.findMany.mockResolvedValue([createdEvent]) + prismaMock.event.findUnique.mockResolvedValue(createdEvent) + ;(prismaMock.event as any).findUniqueOrThrow?.mockResolvedValue?.( + createdEvent + ) + + const result = await authClient({ + document: VIDEO_PLAY_EVENT_CREATE, + variables: { + input: { + id: 'eventId', + blockId: 'blockId', + stepId: 'stepId' + } + } + }) + + expect(result).toEqual({ + data: { + videoPlayEventCreate: expect.objectContaining({ + id: 'eventId', + journeyId: 'journeyId' + }) + } + }) + + expect(prismaMock.event.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + id: 'eventId', + typename: 'VideoPlayEvent', + blockId: 'blockId', + visitor: { connect: { id: 'visitorId' } }, + journey: { connect: { id: 'journeyId' } }, + stepId: 'stepId' + }) + }) + ) + + expect(resetEventsEmailDelay).toHaveBeenCalledWith( + 'journeyId', + 'visitorId', + 40 + ) + }) + + it('uses duration when startAt and endAt are null', async () => { + prismaMock.block.findUnique.mockResolvedValue({ + id: 'blockId', + duration: 200, + startAt: null, + endAt: null + } as any) + + const createdEvent = { + id: 'eventId', + typename: 'VideoPlayEvent', + journeyId: 'journeyId', + visitorId: 'visitorId', + createdAt: new Date() + } as any + + prismaMock.event.create.mockResolvedValue(createdEvent) + prismaMock.event.findMany.mockResolvedValue([createdEvent]) + prismaMock.event.findUnique.mockResolvedValue(createdEvent) + ;(prismaMock.event as any).findUniqueOrThrow?.mockResolvedValue?.( + createdEvent + ) + + await authClient({ + document: VIDEO_PLAY_EVENT_CREATE, + variables: { + input: { + blockId: 'blockId' + } + } + }) + + expect(resetEventsEmailDelay).toHaveBeenCalledWith( + 'journeyId', + 'visitorId', + 200 + ) + }) + + it('uses delay of 0 when video block is not found', async () => { + prismaMock.block.findUnique.mockResolvedValue(null) + + const createdEvent = { + id: 'eventId', + typename: 'VideoPlayEvent', + journeyId: 'journeyId', + visitorId: 'visitorId', + createdAt: new Date() + } as any + + prismaMock.event.create.mockResolvedValue(createdEvent) + prismaMock.event.findMany.mockResolvedValue([createdEvent]) + prismaMock.event.findUnique.mockResolvedValue(createdEvent) + ;(prismaMock.event as any).findUniqueOrThrow?.mockResolvedValue?.( + createdEvent + ) + + await authClient({ + document: VIDEO_PLAY_EVENT_CREATE, + variables: { + input: { + blockId: 'blockId' + } + } + }) + + expect(resetEventsEmailDelay).toHaveBeenCalledWith( + 'journeyId', + 'visitorId', + 0 + ) + }) + + it('returns error when block does not exist', async () => { + const { GraphQLError } = require('graphql') + validateBlockEvent.mockRejectedValue( + new GraphQLError('Block does not exist', { + extensions: { code: 'NOT_FOUND' } + }) + ) + + const result = (await authClient({ + document: VIDEO_PLAY_EVENT_CREATE, + variables: { + input: { + blockId: 'nonExistentBlock' + } + } + })) as any + + expect(result.errors).toBeDefined() + expect(result.errors?.[0]?.message).toBe('Block does not exist') + }) + + it('creates event without optional fields', async () => { + prismaMock.block.findUnique.mockResolvedValue({ + id: 'blockId', + duration: null, + startAt: null, + endAt: null + } as any) + + const createdEvent = { + id: 'auto-id', + typename: 'VideoPlayEvent', + journeyId: 'journeyId', + visitorId: 'visitorId', + createdAt: new Date() + } as any + + prismaMock.event.create.mockResolvedValue(createdEvent) + prismaMock.event.findMany.mockResolvedValue([createdEvent]) + prismaMock.event.findUnique.mockResolvedValue(createdEvent) + ;(prismaMock.event as any).findUniqueOrThrow?.mockResolvedValue?.( + createdEvent + ) + + const result = await authClient({ + document: VIDEO_PLAY_EVENT_CREATE, + variables: { + input: { + blockId: 'blockId' + } + } + }) + + expect(result).toEqual({ + data: { + videoPlayEventCreate: expect.objectContaining({ + id: 'auto-id', + journeyId: 'journeyId' + }) + } + }) + + expect(prismaMock.event.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + typename: 'VideoPlayEvent', + visitor: { connect: { id: 'visitorId' } }, + journey: { connect: { id: 'journeyId' } } + }) + }) + ) + }) +}) diff --git a/apis/api-journeys-modern/src/schema/event/video/videoPlayEventCreate.mutation.ts b/apis/api-journeys-modern/src/schema/event/video/videoPlayEventCreate.mutation.ts new file mode 100644 index 00000000000..d85ab2446a7 --- /dev/null +++ b/apis/api-journeys-modern/src/schema/event/video/videoPlayEventCreate.mutation.ts @@ -0,0 +1,62 @@ +import { prisma } from '@core/prisma/journeys/client' + +import { builder } from '../../builder' +import { resetEventsEmailDelay, validateBlockEvent } from '../utils' + +import { VideoPlayEventCreateInput } from './inputs' +import { VideoPlayEventRef } from './videoPlayEvent' + +builder.mutationField('videoPlayEventCreate', (t) => + t.withAuth({ $any: { isAuthenticated: true, isAnonymous: true } }).field({ + type: VideoPlayEventRef, + nullable: false, + override: { from: 'api-journeys' }, + args: { + input: t.arg({ type: VideoPlayEventCreateInput, required: true }) + }, + resolve: async (_parent, args, context) => { + const { input } = args + const userId = context.user?.id + + if (userId == null) { + throw new Error('User not authenticated') + } + + const { visitor, journeyId } = await validateBlockEvent( + userId, + input.blockId, + input.stepId + ) + + const video = await prisma.block.findUnique({ + where: { id: input.blockId }, + select: { + duration: true, + startAt: true, + endAt: true + } + }) + + const delay = + video?.endAt != null && video?.startAt != null + ? video.endAt - video.startAt + : (video?.duration ?? 0) + + await resetEventsEmailDelay(journeyId, visitor.id, delay) + + return await prisma.event.create({ + data: { + ...(input.id != null ? { id: input.id } : {}), + typename: 'VideoPlayEvent', + blockId: input.blockId, + label: input.label ?? undefined, + value: input.value ?? undefined, + position: input.position ?? undefined, + visitor: { connect: { id: visitor.id } }, + journey: { connect: { id: journeyId } }, + stepId: input.stepId ?? undefined + } + }) + } + }) +) diff --git a/apis/api-journeys-modern/src/schema/event/video/videoProgressEventCreate.mutation.spec.ts b/apis/api-journeys-modern/src/schema/event/video/videoProgressEventCreate.mutation.spec.ts new file mode 100644 index 00000000000..27b545ff123 --- /dev/null +++ b/apis/api-journeys-modern/src/schema/event/video/videoProgressEventCreate.mutation.spec.ts @@ -0,0 +1,174 @@ +import { getClient } from '../../../../test/client' +import { prismaMock } from '../../../../test/prismaMock' +import { graphql } from '../../../lib/graphql/subgraphGraphql' + +jest.mock('../utils', () => ({ + ...jest.requireActual('../utils'), + validateBlockEvent: jest.fn() +})) + +describe('videoProgressEventCreate', () => { + const mockUser = { id: 'userId' } + const authClient = getClient({ + headers: { authorization: 'token' }, + context: { currentUser: mockUser } + }) + + const VIDEO_PROGRESS_EVENT_CREATE = graphql(` + mutation VideoProgressEventCreate( + $input: VideoProgressEventCreateInput! + ) { + videoProgressEventCreate(input: $input) { + id + journeyId + } + } + `) + + const { validateBlockEvent } = require('../utils') + + const mockVisitor = { + id: 'visitorId', + createdAt: new Date('2024-01-01T00:00:00Z'), + userId: 'userId', + teamId: 'teamId' + } + + beforeEach(() => { + validateBlockEvent.mockResolvedValue({ + visitor: mockVisitor, + journeyVisitor: { + journeyId: 'journeyId', + visitorId: 'visitorId' + }, + journeyId: 'journeyId', + teamId: 'teamId', + block: { id: 'blockId', journeyId: 'journeyId' } + }) + }) + + it('creates a VideoProgressEvent when authorized', async () => { + const createdEvent = { + id: 'eventId', + typename: 'VideoProgressEvent', + journeyId: 'journeyId', + visitorId: 'visitorId', + createdAt: new Date(), + position: null, + source: null, + progress: 25 + } as any + + prismaMock.event.create.mockResolvedValue(createdEvent) + prismaMock.event.findMany.mockResolvedValue([createdEvent]) + prismaMock.event.findUnique.mockResolvedValue(createdEvent) + ;(prismaMock.event as any).findUniqueOrThrow?.mockResolvedValue?.( + createdEvent + ) + + const result = await authClient({ + document: VIDEO_PROGRESS_EVENT_CREATE, + variables: { + input: { + id: 'eventId', + blockId: 'blockId', + stepId: 'stepId', + progress: 25 + } + } + }) + + expect(result).toEqual({ + data: { + videoProgressEventCreate: expect.objectContaining({ + id: 'eventId', + journeyId: 'journeyId' + }) + } + }) + + expect(prismaMock.event.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + id: 'eventId', + typename: 'VideoProgressEvent', + blockId: 'blockId', + progress: 25, + visitor: { connect: { id: 'visitorId' } }, + journey: { connect: { id: 'journeyId' } }, + stepId: 'stepId' + }) + }) + ) + }) + + it('returns error when block does not exist', async () => { + const { GraphQLError } = require('graphql') + validateBlockEvent.mockRejectedValue( + new GraphQLError('Block does not exist', { + extensions: { code: 'NOT_FOUND' } + }) + ) + + const result = (await authClient({ + document: VIDEO_PROGRESS_EVENT_CREATE, + variables: { + input: { + blockId: 'nonExistentBlock', + progress: 50 + } + } + })) as any + + expect(result.errors).toBeDefined() + expect(result.errors?.[0]?.message).toBe('Block does not exist') + }) + + it('creates event without optional fields', async () => { + const createdEvent = { + id: 'auto-id', + typename: 'VideoProgressEvent', + journeyId: 'journeyId', + visitorId: 'visitorId', + createdAt: new Date(), + progress: 75 + } as any + + prismaMock.event.create.mockResolvedValue(createdEvent) + prismaMock.event.findMany.mockResolvedValue([createdEvent]) + prismaMock.event.findUnique.mockResolvedValue(createdEvent) + ;(prismaMock.event as any).findUniqueOrThrow?.mockResolvedValue?.( + createdEvent + ) + + const result = await authClient({ + document: VIDEO_PROGRESS_EVENT_CREATE, + variables: { + input: { + blockId: 'blockId', + progress: 75 + } + } + }) + + expect(result).toEqual({ + data: { + videoProgressEventCreate: expect.objectContaining({ + id: 'auto-id', + journeyId: 'journeyId' + }) + } + }) + + expect(prismaMock.event.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + typename: 'VideoProgressEvent', + progress: 75, + visitor: { connect: { id: 'visitorId' } }, + journey: { connect: { id: 'journeyId' } } + }) + }) + ) + }) +}) diff --git a/apis/api-journeys-modern/src/schema/event/video/videoProgressEventCreate.mutation.ts b/apis/api-journeys-modern/src/schema/event/video/videoProgressEventCreate.mutation.ts new file mode 100644 index 00000000000..76ad8563f0a --- /dev/null +++ b/apis/api-journeys-modern/src/schema/event/video/videoProgressEventCreate.mutation.ts @@ -0,0 +1,47 @@ +import { prisma } from '@core/prisma/journeys/client' + +import { builder } from '../../builder' +import { validateBlockEvent } from '../utils' + +import { VideoProgressEventCreateInput } from './inputs' +import { VideoProgressEventRef } from './videoProgressEvent' + +builder.mutationField('videoProgressEventCreate', (t) => + t.withAuth({ $any: { isAuthenticated: true, isAnonymous: true } }).field({ + type: VideoProgressEventRef, + nullable: false, + override: { from: 'api-journeys' }, + args: { + input: t.arg({ type: VideoProgressEventCreateInput, required: true }) + }, + resolve: async (_parent, args, context) => { + const { input } = args + const userId = context.user?.id + + if (userId == null) { + throw new Error('User not authenticated') + } + + const { visitor, journeyId } = await validateBlockEvent( + userId, + input.blockId, + input.stepId + ) + + return await prisma.event.create({ + data: { + ...(input.id != null ? { id: input.id } : {}), + typename: 'VideoProgressEvent', + blockId: input.blockId, + label: input.label ?? undefined, + value: input.value ?? undefined, + position: input.position ?? undefined, + progress: input.progress, + visitor: { connect: { id: visitor.id } }, + journey: { connect: { id: journeyId } }, + stepId: input.stepId ?? undefined + } + }) + } + }) +) diff --git a/apis/api-journeys-modern/src/schema/event/video/videoStartEventCreate.mutation.spec.ts b/apis/api-journeys-modern/src/schema/event/video/videoStartEventCreate.mutation.spec.ts new file mode 100644 index 00000000000..0621928e0fb --- /dev/null +++ b/apis/api-journeys-modern/src/schema/event/video/videoStartEventCreate.mutation.spec.ts @@ -0,0 +1,260 @@ +import { getClient } from '../../../../test/client' +import { prismaMock } from '../../../../test/prismaMock' +import { graphql } from '../../../lib/graphql/subgraphGraphql' + +jest.mock('../utils', () => ({ + ...jest.requireActual('../utils'), + validateBlockEvent: jest.fn(), + resetEventsEmailDelay: jest.fn() +})) + +describe('videoStartEventCreate', () => { + const mockUser = { id: 'userId' } + const authClient = getClient({ + headers: { authorization: 'token' }, + context: { currentUser: mockUser } + }) + + const VIDEO_START_EVENT_CREATE = graphql(` + mutation VideoStartEventCreate($input: VideoStartEventCreateInput!) { + videoStartEventCreate(input: $input) { + id + journeyId + } + } + `) + + const { validateBlockEvent, resetEventsEmailDelay } = require('../utils') + + const mockVisitor = { + id: 'visitorId', + createdAt: new Date('2024-01-01T00:00:00Z'), + userId: 'userId', + teamId: 'teamId' + } + + beforeEach(() => { + validateBlockEvent.mockResolvedValue({ + visitor: mockVisitor, + journeyVisitor: { + journeyId: 'journeyId', + visitorId: 'visitorId' + }, + journeyId: 'journeyId', + teamId: 'teamId', + block: { id: 'blockId', journeyId: 'journeyId' } + }) + resetEventsEmailDelay.mockResolvedValue(undefined) + }) + + it('creates a VideoStartEvent when authorized', async () => { + prismaMock.block.findUnique.mockResolvedValue({ + id: 'blockId', + duration: 120, + startAt: 10, + endAt: 50 + } as any) + + const createdEvent = { + id: 'eventId', + typename: 'VideoStartEvent', + journeyId: 'journeyId', + visitorId: 'visitorId', + createdAt: new Date(), + position: null, + source: null + } as any + + prismaMock.event.create.mockResolvedValue(createdEvent) + prismaMock.event.findMany.mockResolvedValue([createdEvent]) + prismaMock.event.findUnique.mockResolvedValue(createdEvent) + ;(prismaMock.event as any).findUniqueOrThrow?.mockResolvedValue?.( + createdEvent + ) + + const result = await authClient({ + document: VIDEO_START_EVENT_CREATE, + variables: { + input: { + id: 'eventId', + blockId: 'blockId', + stepId: 'stepId' + } + } + }) + + expect(result).toEqual({ + data: { + videoStartEventCreate: expect.objectContaining({ + id: 'eventId', + journeyId: 'journeyId' + }) + } + }) + + expect(prismaMock.event.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + id: 'eventId', + typename: 'VideoStartEvent', + blockId: 'blockId', + visitor: { connect: { id: 'visitorId' } }, + journey: { connect: { id: 'journeyId' } }, + stepId: 'stepId' + }) + }) + ) + + expect(resetEventsEmailDelay).toHaveBeenCalledWith( + 'journeyId', + 'visitorId', + 40 + ) + }) + + it('uses duration when startAt and endAt are null', async () => { + prismaMock.block.findUnique.mockResolvedValue({ + id: 'blockId', + duration: 200, + startAt: null, + endAt: null + } as any) + + const createdEvent = { + id: 'eventId', + typename: 'VideoStartEvent', + journeyId: 'journeyId', + visitorId: 'visitorId', + createdAt: new Date() + } as any + + prismaMock.event.create.mockResolvedValue(createdEvent) + prismaMock.event.findMany.mockResolvedValue([createdEvent]) + prismaMock.event.findUnique.mockResolvedValue(createdEvent) + ;(prismaMock.event as any).findUniqueOrThrow?.mockResolvedValue?.( + createdEvent + ) + + await authClient({ + document: VIDEO_START_EVENT_CREATE, + variables: { + input: { + blockId: 'blockId' + } + } + }) + + expect(resetEventsEmailDelay).toHaveBeenCalledWith( + 'journeyId', + 'visitorId', + 200 + ) + }) + + it('uses delay of 0 when video block is not found', async () => { + prismaMock.block.findUnique.mockResolvedValue(null) + + const createdEvent = { + id: 'eventId', + typename: 'VideoStartEvent', + journeyId: 'journeyId', + visitorId: 'visitorId', + createdAt: new Date() + } as any + + prismaMock.event.create.mockResolvedValue(createdEvent) + prismaMock.event.findMany.mockResolvedValue([createdEvent]) + prismaMock.event.findUnique.mockResolvedValue(createdEvent) + ;(prismaMock.event as any).findUniqueOrThrow?.mockResolvedValue?.( + createdEvent + ) + + await authClient({ + document: VIDEO_START_EVENT_CREATE, + variables: { + input: { + blockId: 'blockId' + } + } + }) + + expect(resetEventsEmailDelay).toHaveBeenCalledWith( + 'journeyId', + 'visitorId', + 0 + ) + }) + + it('returns error when block does not exist', async () => { + const { GraphQLError } = require('graphql') + validateBlockEvent.mockRejectedValue( + new GraphQLError('Block does not exist', { + extensions: { code: 'NOT_FOUND' } + }) + ) + + const result = (await authClient({ + document: VIDEO_START_EVENT_CREATE, + variables: { + input: { + blockId: 'nonExistentBlock' + } + } + })) as any + + expect(result.errors).toBeDefined() + expect(result.errors?.[0]?.message).toBe('Block does not exist') + }) + + it('creates event without optional fields', async () => { + prismaMock.block.findUnique.mockResolvedValue({ + id: 'blockId', + duration: null, + startAt: null, + endAt: null + } as any) + + const createdEvent = { + id: 'auto-id', + typename: 'VideoStartEvent', + journeyId: 'journeyId', + visitorId: 'visitorId', + createdAt: new Date() + } as any + + prismaMock.event.create.mockResolvedValue(createdEvent) + prismaMock.event.findMany.mockResolvedValue([createdEvent]) + prismaMock.event.findUnique.mockResolvedValue(createdEvent) + ;(prismaMock.event as any).findUniqueOrThrow?.mockResolvedValue?.( + createdEvent + ) + + const result = await authClient({ + document: VIDEO_START_EVENT_CREATE, + variables: { + input: { + blockId: 'blockId' + } + } + }) + + expect(result).toEqual({ + data: { + videoStartEventCreate: expect.objectContaining({ + id: 'auto-id', + journeyId: 'journeyId' + }) + } + }) + + expect(prismaMock.event.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + typename: 'VideoStartEvent', + visitor: { connect: { id: 'visitorId' } }, + journey: { connect: { id: 'journeyId' } } + }) + }) + ) + }) +}) diff --git a/apis/api-journeys-modern/src/schema/event/video/videoStartEventCreate.mutation.ts b/apis/api-journeys-modern/src/schema/event/video/videoStartEventCreate.mutation.ts new file mode 100644 index 00000000000..978150f66e0 --- /dev/null +++ b/apis/api-journeys-modern/src/schema/event/video/videoStartEventCreate.mutation.ts @@ -0,0 +1,62 @@ +import { prisma } from '@core/prisma/journeys/client' + +import { builder } from '../../builder' +import { resetEventsEmailDelay, validateBlockEvent } from '../utils' + +import { VideoStartEventCreateInput } from './inputs' +import { VideoStartEventRef } from './videoStartEvent' + +builder.mutationField('videoStartEventCreate', (t) => + t.withAuth({ $any: { isAuthenticated: true, isAnonymous: true } }).field({ + type: VideoStartEventRef, + nullable: false, + override: { from: 'api-journeys' }, + args: { + input: t.arg({ type: VideoStartEventCreateInput, required: true }) + }, + resolve: async (_parent, args, context) => { + const { input } = args + const userId = context.user?.id + + if (userId == null) { + throw new Error('User not authenticated') + } + + const { visitor, journeyId } = await validateBlockEvent( + userId, + input.blockId, + input.stepId + ) + + const video = await prisma.block.findUnique({ + where: { id: input.blockId }, + select: { + duration: true, + startAt: true, + endAt: true + } + }) + + const delay = + video?.endAt != null && video?.startAt != null + ? video.endAt - video.startAt + : (video?.duration ?? 0) + + await resetEventsEmailDelay(journeyId, visitor.id, delay) + + return await prisma.event.create({ + data: { + ...(input.id != null ? { id: input.id } : {}), + typename: 'VideoStartEvent', + blockId: input.blockId, + label: input.label ?? undefined, + value: input.value ?? undefined, + position: input.position ?? undefined, + visitor: { connect: { id: visitor.id } }, + journey: { connect: { id: journeyId } }, + stepId: input.stepId ?? undefined + } + }) + } + }) +) diff --git a/apis/api-journeys/schema.graphql b/apis/api-journeys/schema.graphql index 90ce1b49748..ecbb3d92d58 100644 --- a/apis/api-journeys/schema.graphql +++ b/apis/api-journeys/schema.graphql @@ -219,13 +219,6 @@ input BlockDuplicateIdMap { type Mutation { """blockRestore is used for redo/undo""" blockRestore(id: ID!): [Block!]! - chatButtonCreate(journeyId: ID!, input: ChatButtonCreateInput): ChatButton! - chatButtonUpdate(id: ID!, journeyId: ID!, input: ChatButtonUpdateInput!): ChatButton! - chatButtonRemove(id: ID!): ChatButton! - customDomainCreate(input: CustomDomainCreateInput!): CustomDomain! - customDomainUpdate(id: ID!, input: CustomDomainUpdateInput!): CustomDomain! - customDomainDelete(id: ID!): CustomDomain! - customDomainCheck(id: ID!): CustomDomainCheck! """ Creates a JourneyViewEvent, returns null if attempting to create another @@ -900,17 +893,6 @@ type ChatButton customizable: Boolean } -input ChatButtonCreateInput { - link: String - platform: MessagePlatform -} - -input ChatButtonUpdateInput { - link: String - platform: MessagePlatform - customizable: Boolean -} - type CustomDomain @shareable { @@ -922,125 +904,6 @@ type CustomDomain routeAllTeamJourneys: Boolean! } -type CustomDomainCheck - @shareable -{ - """ - Is the domain correctly configured in the DNS? - If false, A Record and CNAME Record should be added by the user. - """ - configured: Boolean! - - """ - Does the domain belong to the team? - If false, verification and verificationResponse will be populated. - """ - verified: Boolean! - - """Verification records to be added to the DNS to confirm ownership.""" - verification: [CustomDomainVerification!] - - """Reasoning as to why verification is required.""" - verificationResponse: CustomDomainVerificationResponse -} - -input CustomDomainCreateInput { - id: ID - teamId: String! - name: String! - journeyCollectionId: ID - routeAllTeamJourneys: Boolean -} - -input CustomDomainUpdateInput { - journeyCollectionId: ID - routeAllTeamJourneys: Boolean -} - -type CustomDomainVerification - @shareable -{ - type: String! - domain: String! - value: String! - reason: String! -} - -type CustomDomainVerificationResponse - @shareable -{ - code: String! - message: String! -} - -type Query { - _entities(representations: [_Any!]!): [_Entity]! - _service: _Service! -} - -extend type Query { - customDomain(id: ID!): CustomDomain! - customDomains(teamId: ID!): [CustomDomain!]! - hosts(teamId: ID!): [Host!]! - integrations(teamId: ID!): [Integration!]! - adminJourneysReport(reportType: JourneysReportType!): PowerBiEmbed - journeys(where: JourneysFilter, options: JourneysQueryOptions): [Journey!]! - journey(id: ID!, idType: IdType, options: JourneysQueryOptions): Journey! - - """ - Returns distinct language IDs from published global templates. - Used to dynamically populate the language filter on the templates page. - """ - journeyTemplateLanguageIds: [String!]! - journeyCollection(id: ID!): JourneyCollection! - journeyCollections(teamId: ID!): [JourneyCollection]! - journeyEventsConnection(journeyId: ID!, filter: JourneyEventsFilter, first: Int, after: String): JourneyEventsConnection! - journeyEventsCount(journeyId: ID!, filter: JourneyEventsFilter): Int! - journeyTheme(journeyId: ID!): JourneyTheme - - """Get a list of Visitor Information by Journey""" - journeyVisitorsConnection( - """Returns the elements in the list that match the specified filter.""" - filter: JourneyVisitorFilter! - - """Returns the first n elements from the list.""" - first: Int - - """Returns the elements in the list that come after the specified cursor.""" - after: String - - """Specifies the sort field for the list.""" - sort: JourneyVisitorSort - ): JourneyVisitorsConnection! - - """Get a JourneyVisitor count by JourneyVisitorFilter""" - journeyVisitorCount(filter: JourneyVisitorFilter!): Int! - journeysEmailPreference(email: String!): JourneysEmailPreference - qrCode(id: ID!): QrCode! - qrCodes(where: QrCodesFilter!): [QrCode!]! - teams: [Team!]! - team(id: ID!): Team! - userInvites(journeyId: ID!): [UserInvite!] - userTeams(teamId: ID!, where: UserTeamFilterInput): [UserTeam!]! - userTeam(id: ID!): UserTeam! - userTeamInvites(teamId: ID!): [UserTeamInvite!]! - - """A list of visitors that are connected with a specific team.""" - visitorsConnection( - """Returns the visitor items related to a specific team.""" - teamId: String - - """Returns the first n elements from the list.""" - first: Int - - """Returns the elements in the list that come after the specified cursor.""" - after: String - ): VisitorsConnection! - - """Get a single visitor""" - visitor(id: ID!): Visitor! -} - enum ButtonAction { NavigateToBlockAction LinkAction @@ -1713,6 +1576,72 @@ input HostUpdateInput { src2: String } +type Query { + _entities(representations: [_Any!]!): [_Entity]! + _service: _Service! +} + +extend type Query { + hosts(teamId: ID!): [Host!]! + integrations(teamId: ID!): [Integration!]! + adminJourneysReport(reportType: JourneysReportType!): PowerBiEmbed + journeys(where: JourneysFilter, options: JourneysQueryOptions): [Journey!]! + journey(id: ID!, idType: IdType, options: JourneysQueryOptions): Journey! + + """ + Returns distinct language IDs from published global templates. + Used to dynamically populate the language filter on the templates page. + """ + journeyTemplateLanguageIds: [String!]! + journeyCollection(id: ID!): JourneyCollection! + journeyCollections(teamId: ID!): [JourneyCollection]! + journeyEventsConnection(journeyId: ID!, filter: JourneyEventsFilter, first: Int, after: String): JourneyEventsConnection! + journeyEventsCount(journeyId: ID!, filter: JourneyEventsFilter): Int! + journeyTheme(journeyId: ID!): JourneyTheme + + """Get a list of Visitor Information by Journey""" + journeyVisitorsConnection( + """Returns the elements in the list that match the specified filter.""" + filter: JourneyVisitorFilter! + + """Returns the first n elements from the list.""" + first: Int + + """Returns the elements in the list that come after the specified cursor.""" + after: String + + """Specifies the sort field for the list.""" + sort: JourneyVisitorSort + ): JourneyVisitorsConnection! + + """Get a JourneyVisitor count by JourneyVisitorFilter""" + journeyVisitorCount(filter: JourneyVisitorFilter!): Int! + journeysEmailPreference(email: String!): JourneysEmailPreference + qrCode(id: ID!): QrCode! + qrCodes(where: QrCodesFilter!): [QrCode!]! + teams: [Team!]! + team(id: ID!): Team! + userInvites(journeyId: ID!): [UserInvite!] + userTeams(teamId: ID!, where: UserTeamFilterInput): [UserTeam!]! + userTeam(id: ID!): UserTeam! + userTeamInvites(teamId: ID!): [UserTeamInvite!]! + + """A list of visitors that are connected with a specific team.""" + visitorsConnection( + """Returns the visitor items related to a specific team.""" + teamId: String + + """Returns the first n elements from the list.""" + first: Int + + """Returns the elements in the list that come after the specified cursor.""" + after: String + ): VisitorsConnection! + + """Get a single visitor""" + visitor(id: ID!): Visitor! +} + input HostCreateInput { title: String! location: String diff --git a/apis/api-journeys/src/app/__generated__/graphql.ts b/apis/api-journeys/src/app/__generated__/graphql.ts index a58caa77aa5..2043e1457c1 100644 --- a/apis/api-journeys/src/app/__generated__/graphql.ts +++ b/apis/api-journeys/src/app/__generated__/graphql.ts @@ -320,30 +320,6 @@ export class TypographyBlockSettingsInput { color?: Nullable; } -export class ChatButtonCreateInput { - link?: Nullable; - platform?: Nullable; -} - -export class ChatButtonUpdateInput { - link?: Nullable; - platform?: Nullable; - customizable?: Nullable; -} - -export class CustomDomainCreateInput { - id?: Nullable; - teamId: string; - name: string; - journeyCollectionId?: Nullable; - routeAllTeamJourneys?: Nullable; -} - -export class CustomDomainUpdateInput { - journeyCollectionId?: Nullable; - routeAllTeamJourneys?: Nullable; -} - export class JourneyViewEventCreateInput { id?: Nullable; journeyId: string; @@ -785,20 +761,6 @@ export abstract class IMutation { abstract blockRestore(id: string): Block[] | Promise; - abstract chatButtonCreate(journeyId: string, input?: Nullable): ChatButton | Promise; - - abstract chatButtonUpdate(id: string, journeyId: string, input: ChatButtonUpdateInput): ChatButton | Promise; - - abstract chatButtonRemove(id: string): ChatButton | Promise; - - abstract customDomainCreate(input: CustomDomainCreateInput): CustomDomain | Promise; - - abstract customDomainUpdate(id: string, input: CustomDomainUpdateInput): CustomDomain | Promise; - - abstract customDomainDelete(id: string): CustomDomain | Promise; - - abstract customDomainCheck(id: string): CustomDomainCheck | Promise; - abstract journeyViewEventCreate(input: JourneyViewEventCreateInput): Nullable | Promise>; abstract stepViewEventCreate(input: StepViewEventCreateInput): StepViewEvent | Promise; @@ -1172,84 +1134,6 @@ export class CustomDomain { routeAllTeamJourneys: boolean; } -export class CustomDomainCheck { - __typename?: 'CustomDomainCheck'; - configured: boolean; - verified: boolean; - verification?: Nullable; - verificationResponse?: Nullable; -} - -export class CustomDomainVerification { - __typename?: 'CustomDomainVerification'; - type: string; - domain: string; - value: string; - reason: string; -} - -export class CustomDomainVerificationResponse { - __typename?: 'CustomDomainVerificationResponse'; - code: string; - message: string; -} - -export abstract class IQuery { - __typename?: 'IQuery'; - - abstract customDomain(id: string): CustomDomain | Promise; - - abstract customDomains(teamId: string): CustomDomain[] | Promise; - - abstract hosts(teamId: string): Host[] | Promise; - - abstract integrations(teamId: string): Integration[] | Promise; - - abstract adminJourneysReport(reportType: JourneysReportType): Nullable | Promise>; - - abstract journeys(where?: Nullable, options?: Nullable): Journey[] | Promise; - - abstract journey(id: string, idType?: Nullable, options?: Nullable): Journey | Promise; - - abstract journeyTemplateLanguageIds(): string[] | Promise; - - abstract journeyCollection(id: string): JourneyCollection | Promise; - - abstract journeyCollections(teamId: string): Nullable[] | Promise[]>; - - abstract journeyEventsConnection(journeyId: string, filter?: Nullable, first?: Nullable, after?: Nullable): JourneyEventsConnection | Promise; - - abstract journeyEventsCount(journeyId: string, filter?: Nullable): number | Promise; - - abstract journeyTheme(journeyId: string): Nullable | Promise>; - - abstract journeyVisitorsConnection(filter: JourneyVisitorFilter, first?: Nullable, after?: Nullable, sort?: Nullable): JourneyVisitorsConnection | Promise; - - abstract journeyVisitorCount(filter: JourneyVisitorFilter): number | Promise; - - abstract journeysEmailPreference(email: string): Nullable | Promise>; - - abstract qrCode(id: string): QrCode | Promise; - - abstract qrCodes(where: QrCodesFilter): QrCode[] | Promise; - - abstract teams(): Team[] | Promise; - - abstract team(id: string): Team | Promise; - - abstract userInvites(journeyId: string): Nullable | Promise>; - - abstract userTeams(teamId: string, where?: Nullable): UserTeam[] | Promise; - - abstract userTeam(id: string): UserTeam | Promise; - - abstract userTeamInvites(teamId: string): UserTeamInvite[] | Promise; - - abstract visitorsConnection(teamId?: Nullable, first?: Nullable, after?: Nullable): VisitorsConnection | Promise; - - abstract visitor(id: string): Visitor | Promise; -} - export class ButtonClickEvent implements Event { __typename?: 'ButtonClickEvent'; id: string; @@ -1434,6 +1318,58 @@ export class Host { src2?: Nullable; } +export abstract class IQuery { + __typename?: 'IQuery'; + + abstract hosts(teamId: string): Host[] | Promise; + + abstract integrations(teamId: string): Integration[] | Promise; + + abstract adminJourneysReport(reportType: JourneysReportType): Nullable | Promise>; + + abstract journeys(where?: Nullable, options?: Nullable): Journey[] | Promise; + + abstract journey(id: string, idType?: Nullable, options?: Nullable): Journey | Promise; + + abstract journeyTemplateLanguageIds(): string[] | Promise; + + abstract journeyCollection(id: string): JourneyCollection | Promise; + + abstract journeyCollections(teamId: string): Nullable[] | Promise[]>; + + abstract journeyEventsConnection(journeyId: string, filter?: Nullable, first?: Nullable, after?: Nullable): JourneyEventsConnection | Promise; + + abstract journeyEventsCount(journeyId: string, filter?: Nullable): number | Promise; + + abstract journeyTheme(journeyId: string): Nullable | Promise>; + + abstract journeyVisitorsConnection(filter: JourneyVisitorFilter, first?: Nullable, after?: Nullable, sort?: Nullable): JourneyVisitorsConnection | Promise; + + abstract journeyVisitorCount(filter: JourneyVisitorFilter): number | Promise; + + abstract journeysEmailPreference(email: string): Nullable | Promise>; + + abstract qrCode(id: string): QrCode | Promise; + + abstract qrCodes(where: QrCodesFilter): QrCode[] | Promise; + + abstract teams(): Team[] | Promise; + + abstract team(id: string): Team | Promise; + + abstract userInvites(journeyId: string): Nullable | Promise>; + + abstract userTeams(teamId: string, where?: Nullable): UserTeam[] | Promise; + + abstract userTeam(id: string): UserTeam | Promise; + + abstract userTeamInvites(teamId: string): UserTeamInvite[] | Promise; + + abstract visitorsConnection(teamId?: Nullable, first?: Nullable, after?: Nullable): VisitorsConnection | Promise; + + abstract visitor(id: string): Visitor | Promise; +} + export class IntegrationGoogle implements Integration { __typename?: 'IntegrationGoogle'; id: string; diff --git a/apis/api-journeys/src/app/app.module.ts b/apis/api-journeys/src/app/app.module.ts index 5692fd113eb..3903727457c 100644 --- a/apis/api-journeys/src/app/app.module.ts +++ b/apis/api-journeys/src/app/app.module.ts @@ -14,7 +14,6 @@ import { LoggerModule } from 'nestjs-pino' import { PrismaModule } from './lib/prisma.module' import { ActionModule } from './modules/action/action.module' import { BlockModule } from './modules/block/block.module' -import { CustomDomainModule } from './modules/customDomain/customDomain.module' import { EventModule } from './modules/event/event.module' import { NestHealthModule } from './modules/health/health.module' import { HostModule } from './modules/host/host.module' @@ -43,7 +42,6 @@ import { VisitorModule } from './modules/visitor/visitor.module' PrismaModule, ActionModule, BlockModule, - CustomDomainModule, EventModule, HostModule, IntegrationModule, diff --git a/apis/api-journeys/src/app/lib/casl/caslFactory/caslFactory.ts b/apis/api-journeys/src/app/lib/casl/caslFactory/caslFactory.ts index 1cac738b399..a7a7b1d3614 100644 --- a/apis/api-journeys/src/app/lib/casl/caslFactory/caslFactory.ts +++ b/apis/api-journeys/src/app/lib/casl/caslFactory/caslFactory.ts @@ -4,7 +4,6 @@ import { Injectable } from '@nestjs/common' import { Role } from '@core/prisma/journeys/client' import { blockAcl } from '../../../modules/block/block.acl' -import { customDomainAcl } from '../../../modules/customDomain/customDomain.acl' import { eventAcl } from '../../../modules/event/event.acl' import { hostAcl } from '../../../modules/host/host.acl' import { integrationAcl } from '../../../modules/integration/integration.acl' @@ -56,7 +55,6 @@ export class AppCaslFactory extends CaslFactory { ) const acls = [ blockAcl, - customDomainAcl, eventAcl, hostAcl, integrationAcl, diff --git a/apis/api-journeys/src/app/modules/chatButton/chatButton.graphql b/apis/api-journeys/src/app/modules/chatButton/chatButton.graphql index 99c91c5f3a5..11d2737cd57 100644 --- a/apis/api-journeys/src/app/modules/chatButton/chatButton.graphql +++ b/apis/api-journeys/src/app/modules/chatButton/chatButton.graphql @@ -8,24 +8,3 @@ type ChatButton @shareable { extend type Journey { chatButtons: [ChatButton!]! } - -input ChatButtonCreateInput { - link: String - platform: MessagePlatform -} - -input ChatButtonUpdateInput { - link: String - platform: MessagePlatform - customizable: Boolean -} - -extend type Mutation { - chatButtonCreate(journeyId: ID!, input: ChatButtonCreateInput): ChatButton! - chatButtonUpdate( - id: ID! - journeyId: ID! - input: ChatButtonUpdateInput! - ): ChatButton! - chatButtonRemove(id: ID!): ChatButton! -} diff --git a/apis/api-journeys/src/app/modules/chatButton/chatButton.resolver.spec.ts b/apis/api-journeys/src/app/modules/chatButton/chatButton.resolver.spec.ts deleted file mode 100644 index 3425ba18669..00000000000 --- a/apis/api-journeys/src/app/modules/chatButton/chatButton.resolver.spec.ts +++ /dev/null @@ -1,178 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing' -import { DeepMockProxy, mockDeep } from 'jest-mock-extended' - -import { MessagePlatform } from '../../__generated__/graphql' -import { PrismaService } from '../../lib/prisma.service' -import { JourneyCustomizableService } from '../journey/journeyCustomizable.service' - -import { ChatButtonResolver } from './chatButton.resolver' - -describe('ChatButtonResolver', () => { - let resolver: ChatButtonResolver, - prismaService: PrismaService, - journeyCustomizableService: DeepMockProxy - - const chatButton = { - journeyId: 'journeyId', - id: '1', - link: 'm.me./user', - platform: 'facebook', - customizable: null - } - - const chatButton2 = { - journeyId: 'journeyId', - id: '2', - link: 'm.me./user2', - platform: 'facebook', - customizable: null - } - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - providers: [ - ChatButtonResolver, - { provide: PrismaService, useValue: mockDeep() }, - { - provide: JourneyCustomizableService, - useValue: mockDeep() - } - ] - }).compile() - resolver = module.get(ChatButtonResolver) - prismaService = module.get(PrismaService) - journeyCustomizableService = module.get( - JourneyCustomizableService - ) as DeepMockProxy - prismaService.chatButton.findMany = jest.fn().mockReturnValue([]) - }) - - it('should create a new ChatButton', async () => { - prismaService.chatButton.create = jest - .fn() - .mockReturnValue([{ journeyId: 'journeyId', id: '1' }]) - - const result = await resolver.chatButtonCreate('journeyId', {}) - expect(result).toEqual([{ id: '1', journeyId: 'journeyId' }]) - }) - - it('should create a new custom ChatButton', async () => { - prismaService.chatButton.create = jest.fn().mockReturnValue([ - { - journeyId: 'journeyId', - id: '1', - input: { platform: MessagePlatform.custom } - } - ]) - - const result = await resolver.chatButtonCreate('journeyId', {}) - expect(result).toEqual([ - { - id: '1', - journeyId: 'journeyId', - input: { platform: MessagePlatform.custom } - } - ]) - }) - - it('should not create more than two ChatButtons', async () => { - prismaService.chatButton.findMany = jest - .fn() - .mockReturnValue([chatButton, chatButton2]) - prismaService.chatButton.create = jest - .fn() - .mockReturnValue([{ journeyId: 'journeyId', id: '3' }]) - - await expect(resolver.chatButtonCreate('journeyId', {})).rejects.toThrow( - 'There are already 2 chat buttons associated with the given journey' - ) - }) - - it('should update an existing ChatButton', async () => { - prismaService.chatButton.findMany = jest.fn().mockReturnValue([chatButton]) - prismaService.chatButton.update = jest.fn().mockReturnValue({ - ...chatButton, - link: 'm.me/username', - platform: 'viber' - }) - - const result = await resolver.chatButtonUpdate('1', 'journeyId', { - link: 'm.me/username', - platform: MessagePlatform.viber - }) - expect(result).toEqual({ - id: '1', - journeyId: 'journeyId', - link: 'm.me/username', - platform: 'viber', - customizable: null - }) - }) - - it('should update customizable field on a ChatButton', async () => { - prismaService.chatButton.findMany = jest.fn().mockReturnValue([chatButton]) - prismaService.chatButton.update = jest - .fn() - .mockReturnValue({ ...chatButton, customizable: true }) - - const result = await resolver.chatButtonUpdate('1', 'journeyId', { - customizable: true - }) - expect(prismaService.chatButton.update).toHaveBeenCalledWith({ - where: { id: '1' }, - data: { journeyId: 'journeyId', customizable: true } - }) - expect(result).toEqual({ - ...chatButton, - customizable: true - }) - }) - - it('should forward customizable null to prisma update', async () => { - prismaService.chatButton.findMany = jest.fn().mockReturnValue([chatButton]) - prismaService.chatButton.update = jest - .fn() - .mockReturnValue({ ...chatButton, customizable: null }) - - await resolver.chatButtonUpdate('1', 'journeyId', { - customizable: null - }) - expect(prismaService.chatButton.update).toHaveBeenCalledWith({ - where: { id: '1' }, - data: { journeyId: 'journeyId', customizable: null } - }) - }) - - it('should delete an existing ChatButton', async () => { - prismaService.chatButton.findMany = jest.fn().mockReturnValue([chatButton]) - prismaService.chatButton.delete = jest.fn().mockReturnValue(chatButton) - - const result = await resolver.chatButtonRemove('1') - expect(result).toEqual(chatButton) - }) - - it('should call recalculate after chatButtonUpdate', async () => { - prismaService.chatButton.findMany = jest.fn().mockReturnValue([chatButton]) - prismaService.chatButton.update = jest - .fn() - .mockReturnValue({ ...chatButton, customizable: true }) - - await resolver.chatButtonUpdate('1', 'journeyId', { - customizable: true - }) - - expect(journeyCustomizableService.recalculate).toHaveBeenCalledWith( - 'journeyId' - ) - }) - - it('should call recalculate after chatButtonRemove', async () => { - prismaService.chatButton.delete = jest.fn().mockReturnValue(chatButton) - - await resolver.chatButtonRemove('1') - - expect(journeyCustomizableService.recalculate).toHaveBeenCalledWith( - 'journeyId' - ) - }) -}) diff --git a/apis/api-journeys/src/app/modules/chatButton/chatButton.resolver.ts b/apis/api-journeys/src/app/modules/chatButton/chatButton.resolver.ts deleted file mode 100644 index 304f2d04dc0..00000000000 --- a/apis/api-journeys/src/app/modules/chatButton/chatButton.resolver.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { Args, Mutation, Resolver } from '@nestjs/graphql' - -import { ChatButton } from '@core/prisma/journeys/client' - -import { - ChatButtonCreateInput, - ChatButtonUpdateInput -} from '../../__generated__/graphql' -import { PrismaService } from '../../lib/prisma.service' -import { JourneyCustomizableService } from '../journey/journeyCustomizable.service' - -@Resolver('ChatButton') -export class ChatButtonResolver { - constructor( - private readonly prismaService: PrismaService, - private readonly journeyCustomizableService: JourneyCustomizableService - ) {} - - @Mutation() - async chatButtonCreate( - @Args('journeyId') journeyId: string, - @Args('input') input: ChatButtonCreateInput - ): Promise { - const chatButtons = await this.prismaService.chatButton.findMany({ - where: { journeyId } - }) - - if (chatButtons.length < 2) { - return await this.prismaService.chatButton.create({ - data: { - journeyId, - ...input - } - }) - } else { - throw new Error( - 'There are already 2 chat buttons associated with the given journey' - ) - } - } - - @Mutation() - async chatButtonUpdate( - @Args('id') id: string, - @Args('journeyId') journeyId: string, - @Args('input') input: ChatButtonUpdateInput - ): Promise { - const result = await this.prismaService.chatButton.update({ - where: { - id - }, - data: { - journeyId, - ...input - } - }) - await this.journeyCustomizableService.recalculate(journeyId) - return result - } - - @Mutation() - async chatButtonRemove(@Args('id') id: string): Promise { - const result = await this.prismaService.chatButton.delete({ - where: { - id - } - }) - await this.journeyCustomizableService.recalculate(result.journeyId) - return result - } -} diff --git a/apis/api-journeys/src/app/modules/customDomain/customDomain.acl.spec.ts b/apis/api-journeys/src/app/modules/customDomain/customDomain.acl.spec.ts deleted file mode 100644 index bc2c85d7c89..00000000000 --- a/apis/api-journeys/src/app/modules/customDomain/customDomain.acl.spec.ts +++ /dev/null @@ -1,244 +0,0 @@ -import { subject } from '@casl/ability' -import { Test, TestingModule } from '@nestjs/testing' - -import { UserJourneyRole, UserTeamRole } from '../../__generated__/graphql' -import { Action, AppAbility, AppCaslFactory } from '../../lib/casl/caslFactory' - -describe('customDomainAcl', () => { - let factory: AppCaslFactory, ability: AppAbility - const customDomain = { - id: 'cd', - teamId: 'teamId', - name: 'name.com', - apexName: 'name.com', - routeAllTeamJourneys: true, - journeyCollectionId: null - } - const user = { id: 'userId' } - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - providers: [AppCaslFactory] - }).compile() - factory = module.get(AppCaslFactory) - ability = await factory.createAbility(user) - }) - - describe('Create', () => { - it('should allow when user is team manager', () => { - expect( - ability.can( - Action.Create, - subject('CustomDomain', { - ...customDomain, - team: { - userTeams: [ - { - userId: 'userId', - role: UserTeamRole.manager - } - ] - } - }) - ) - ).toBe(true) - }) - - it('should not allow when user is not team manager', () => { - expect( - ability.can( - Action.Create, - subject('CustomDomain', { - ...customDomain, - team: { - userTeams: [ - { - userId: 'userId', - role: UserTeamRole.member - } - ] - } - }) - ) - ).toBe(false) - }) - }) - - describe('Manage', () => { - it('should allow when user is team manager', () => { - expect( - ability.can( - Action.Manage, - subject('CustomDomain', { - ...customDomain, - team: { - userTeams: [ - { - userId: 'userId', - role: UserTeamRole.manager - } - ] - } - }) - ) - ).toBe(true) - }) - - it('should not allow when user is not team manager', () => { - expect( - ability.can( - Action.Manage, - subject('CustomDomain', { - ...customDomain, - team: { - userTeams: [ - { - userId: 'userId', - role: UserTeamRole.member - } - ] - } - }) - ) - ).toBe(false) - }) - }) - - describe('Delete', () => { - it('should allow when user is team manager', () => { - expect( - ability.can( - Action.Delete, - subject('CustomDomain', { - ...customDomain, - team: { - userTeams: [ - { - userId: 'userId', - role: UserTeamRole.manager - } - ] - } - }) - ) - ).toBe(true) - }) - - it('should not allow when user is not team manager', () => { - expect( - ability.can( - Action.Delete, - subject('CustomDomain', { - ...customDomain, - team: { - userTeams: [ - { - userId: 'userId', - role: UserTeamRole.member - } - ] - } - }) - ) - ).toBe(false) - }) - }) - - describe('Read', () => { - it('should allow when user is team manager', () => { - expect( - ability.can( - Action.Read, - subject('CustomDomain', { - ...customDomain, - team: { - userTeams: [ - { - userId: 'userId', - role: UserTeamRole.manager - } - ] - } - }) - ) - ).toBe(true) - }) - - it('should allow when user is team member', () => { - expect( - ability.can( - Action.Read, - subject('CustomDomain', { - ...customDomain, - team: { - userTeams: [ - { - userId: 'userId', - role: UserTeamRole.member - } - ] - } - }) - ) - ).toBe(true) - }) - - it('should not allow when user is not team member, journey owner or journey editor', () => { - expect( - ability.can( - Action.Read, - subject('CustomDomain', { - ...customDomain, - team: {} - }) - ) - ).toBe(false) - }) - - it('should allow when user is journey owner', () => { - expect( - ability.can( - Action.Read, - subject('CustomDomain', { - ...customDomain, - team: { - journeys: [ - { - userJourneys: [ - { - userId: 'userId', - role: UserJourneyRole.editor - } - ] - } - ] - } - }) - ) - ).toBe(true) - }) - - it('should allow when user is journey editor', () => { - expect( - ability.can( - Action.Read, - subject('CustomDomain', { - ...customDomain, - team: { - journeys: [ - { - userJourneys: [ - { - userId: 'userId', - role: UserJourneyRole.editor - } - ] - } - ] - } - }) - ) - ).toBe(true) - }) - }) -}) diff --git a/apis/api-journeys/src/app/modules/customDomain/customDomain.acl.ts b/apis/api-journeys/src/app/modules/customDomain/customDomain.acl.ts deleted file mode 100644 index 064c2e8ffd1..00000000000 --- a/apis/api-journeys/src/app/modules/customDomain/customDomain.acl.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { UserTeamRole } from '@core/prisma/journeys/client' - -import { UserJourneyRole } from '../../__generated__/graphql' -import { Action, AppAclFn, AppAclParameters } from '../../lib/casl/caslFactory' - -export const customDomainAcl: AppAclFn = ({ can, user }: AppAclParameters) => { - // custom domain as a team manager - can([Action.Create, Action.Update, Action.Manage], 'CustomDomain', { - team: { - is: { - userTeams: { - some: { - userId: user.id, - role: UserTeamRole.manager - } - } - } - } - }) - // read as manager or member of team - can(Action.Read, 'CustomDomain', { - team: { - is: { - userTeams: { - some: { - userId: user.id, - role: { in: [UserTeamRole.manager, UserTeamRole.member] } - } - } - } - } - }) - // read as editor or owner of journey - can(Action.Read, 'CustomDomain', { - team: { - is: { - journeys: { - some: { - userJourneys: { - some: { - userId: user.id, - role: { - in: [UserJourneyRole.owner, UserJourneyRole.editor] - } - } - } - } - } - } - } - }) -} diff --git a/apis/api-journeys/src/app/modules/customDomain/customDomain.graphql b/apis/api-journeys/src/app/modules/customDomain/customDomain.graphql index a4179c36f6a..53a65b55f39 100644 --- a/apis/api-journeys/src/app/modules/customDomain/customDomain.graphql +++ b/apis/api-journeys/src/app/modules/customDomain/customDomain.graphql @@ -6,61 +6,3 @@ type CustomDomain @shareable { journeyCollection: JourneyCollection routeAllTeamJourneys: Boolean! } - -type CustomDomainCheck @shareable { - """ - Is the domain correctly configured in the DNS? - If false, A Record and CNAME Record should be added by the user. - """ - configured: Boolean! - """ - Does the domain belong to the team? - If false, verification and verificationResponse will be populated. - """ - verified: Boolean! - """ - Verification records to be added to the DNS to confirm ownership. - """ - verification: [CustomDomainVerification!] - """ - Reasoning as to why verification is required. - """ - verificationResponse: CustomDomainVerificationResponse -} - -input CustomDomainCreateInput { - id: ID - teamId: String! - name: String! - journeyCollectionId: ID - routeAllTeamJourneys: Boolean -} - -input CustomDomainUpdateInput { - journeyCollectionId: ID - routeAllTeamJourneys: Boolean -} - -type CustomDomainVerification @shareable { - type: String! - domain: String! - value: String! - reason: String! -} - -type CustomDomainVerificationResponse @shareable { - code: String! - message: String! -} - -extend type Mutation { - customDomainCreate(input: CustomDomainCreateInput!): CustomDomain! - customDomainUpdate(id: ID!, input: CustomDomainUpdateInput!): CustomDomain! - customDomainDelete(id: ID!): CustomDomain! - customDomainCheck(id: ID!): CustomDomainCheck! -} - -extend type Query { - customDomain(id: ID!): CustomDomain! - customDomains(teamId: ID!): [CustomDomain!]! -} diff --git a/apis/api-journeys/src/app/modules/customDomain/customDomain.module.ts b/apis/api-journeys/src/app/modules/customDomain/customDomain.module.ts deleted file mode 100644 index e45ef97fe95..00000000000 --- a/apis/api-journeys/src/app/modules/customDomain/customDomain.module.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { Global, Module } from '@nestjs/common' - -import { AppCaslFactory } from '../../lib/casl/caslFactory' -import { CaslAuthModule } from '../../lib/CaslAuthModule' -import { prismaServiceProvider } from '../../lib/prisma.service' -import { QrCodeService } from '../qrCode/qrCode.service' - -import { CustomDomainResolver } from './customDomain.resolver' -import { CustomDomainService } from './customDomain.service' - -@Global() -@Module({ - imports: [CaslAuthModule.register(AppCaslFactory)], - providers: [ - CustomDomainResolver, - prismaServiceProvider, - CustomDomainService, - QrCodeService - ], - exports: [CustomDomainResolver] -}) -export class CustomDomainModule {} diff --git a/apis/api-journeys/src/app/modules/customDomain/customDomain.resolver.spec.ts b/apis/api-journeys/src/app/modules/customDomain/customDomain.resolver.spec.ts deleted file mode 100644 index 4feacb93837..00000000000 --- a/apis/api-journeys/src/app/modules/customDomain/customDomain.resolver.spec.ts +++ /dev/null @@ -1,438 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing' -import { DeepMockProxy, mockDeep } from 'jest-mock-extended' -import omit from 'lodash/omit' - -import { - CustomDomain, - Journey, - JourneyCollection, - Prisma, - Team, - UserTeamRole -} from '@core/prisma/journeys/client' - -import { - CustomDomainCreateInput, - CustomDomainUpdateInput -} from '../../__generated__/graphql' -import { AppAbility, AppCaslFactory } from '../../lib/casl/caslFactory' -import { CaslAuthModule } from '../../lib/CaslAuthModule' -import { PrismaService } from '../../lib/prisma.service' -import { ERROR_PSQL_UNIQUE_CONSTRAINT_VIOLATED } from '../../lib/prismaErrors' -import { QrCodeService } from '../qrCode/qrCode.service' - -import { CustomDomainResolver } from './customDomain.resolver' -import { CustomDomainService } from './customDomain.service' - -describe('CustomDomainResolver', () => { - let resolver: CustomDomainResolver, - service: DeepMockProxy, - prismaService: DeepMockProxy, - ability: AppAbility - - const customDomain: CustomDomain = { - id: 'customDomainId', - teamId: 'teamId', - name: 'name.com', - apexName: 'name.com', - routeAllTeamJourneys: true, - journeyCollectionId: null - } - const customDomainWithUserTeam = { - ...customDomain, - team: { userTeams: [{ userId: 'userId', role: UserTeamRole.manager }] } - } - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - imports: [CaslAuthModule.register(AppCaslFactory)], - providers: [ - CustomDomainResolver, - { - provide: CustomDomainService, - useValue: mockDeep() - }, - { - provide: PrismaService, - useValue: mockDeep() - }, - { - provide: QrCodeService, - useValue: mockDeep() - } - ] - }).compile() - resolver = module.get(CustomDomainResolver) - service = - module.get>(CustomDomainService) - prismaService = module.get>(PrismaService) - ability = await new AppCaslFactory().createAbility({ id: 'userId' }) - }) - - afterEach(() => { - jest.clearAllMocks() - }) - - describe('customDomain', () => { - it('should return a custom domain', async () => { - prismaService.customDomain.findUnique.mockResolvedValueOnce( - customDomainWithUserTeam - ) - expect(await resolver.customDomain('customDomainId', ability)).toEqual( - customDomainWithUserTeam - ) - expect(prismaService.customDomain.findUnique).toHaveBeenCalledWith({ - where: { id: 'customDomainId' }, - include: { - team: { - include: { - userTeams: true, - journeys: { - include: { - userJourneys: true - } - } - } - } - } - }) - }) - - it('should handle not found', async () => { - prismaService.customDomain.findUnique.mockResolvedValueOnce(null) - await expect( - resolver.customDomain('customDomainId', ability) - ).rejects.toThrow('custom domain not found') - }) - - it('should handle not allowed', async () => { - prismaService.customDomain.findUnique.mockResolvedValueOnce(customDomain) - await expect( - resolver.customDomain('customDomainId', ability) - ).rejects.toThrow('user is not allowed to read custom domain') - }) - }) - - describe('customDomains', () => { - it('should return custom domains', async () => { - const accessibleCustomDomains: Prisma.CustomDomainWhereInput = { - OR: [{}] - } - prismaService.customDomain.findMany.mockResolvedValue([customDomain]) - expect( - await resolver.customDomains('teamId', accessibleCustomDomains) - ).toEqual([customDomain]) - expect(prismaService.customDomain.findMany).toHaveBeenCalledWith({ - where: { AND: [{ OR: [{}] }, { teamId: 'teamId' }] } - }) - }) - }) - - describe('customDomainCreate', () => { - beforeEach(() => { - prismaService.$transaction.mockImplementation( - async (callback) => await callback(prismaService) - ) - }) - - it('should create a custom domain', async () => { - const input: CustomDomainCreateInput = { - name: 'www.example.com', - teamId: 'teamId' - } - service.isDomainValid.mockReturnValueOnce(true) - service.createVercelDomain.mockResolvedValue({ - name: 'www.example.com', - apexName: 'example.com' - }) - prismaService.customDomain.create.mockResolvedValue( - customDomainWithUserTeam - ) - expect(await resolver.customDomainCreate(input, ability)).toEqual( - customDomainWithUserTeam - ) - }) - - it('should create a custom domain with advanced input', async () => { - const input: CustomDomainCreateInput = { - id: 'customDomainId', - name: 'www.example.com', - teamId: 'teamId', - routeAllTeamJourneys: true, - journeyCollectionId: 'journeyCollectionId' - } - service.isDomainValid.mockReturnValueOnce(true) - service.createVercelDomain.mockResolvedValue({ - name: 'www.example.com', - apexName: 'example.com' - }) - prismaService.customDomain.create.mockResolvedValue( - customDomainWithUserTeam - ) - expect(await resolver.customDomainCreate(input, ability)).toEqual( - customDomainWithUserTeam - ) - expect(prismaService.customDomain.create).toHaveBeenCalledWith({ - data: { - ...omit(input, ['teamId', 'journeyCollectionId']), - apexName: 'example.com', - team: { connect: { id: 'teamId' } }, - journeyCollection: { - connect: { id: 'journeyCollectionId' } - } - }, - include: { team: { include: { userTeams: true } } } - }) - }) - - it('should handle invalid domain name', async () => { - const input: CustomDomainCreateInput = { - name: 'www.example.com', - teamId: 'teamId' - } - service.isDomainValid.mockReturnValueOnce(false) - await expect(resolver.customDomainCreate(input, ability)).rejects.toThrow( - 'custom domain has invalid domain name' - ) - }) - - it('should handle custom domain already exists', async () => { - const input: CustomDomainCreateInput = { - name: 'www.example.com', - teamId: 'teamId' - } - service.isDomainValid.mockReturnValueOnce(true) - service.createVercelDomain.mockResolvedValue({ - name: 'www.example.com', - apexName: 'example.com' - }) - prismaService.customDomain.create.mockRejectedValueOnce( - new Prisma.PrismaClientKnownRequestError('', { - code: ERROR_PSQL_UNIQUE_CONSTRAINT_VIOLATED, - clientVersion: '' - }) - ) - await expect(resolver.customDomainCreate(input, ability)).rejects.toThrow( - 'custom domain already exists' - ) - }) - - it('should handle not allowed', async () => { - const input: CustomDomainCreateInput = { - name: 'www.example.com', - teamId: 'teamId' - } - service.isDomainValid.mockReturnValueOnce(true) - service.createVercelDomain.mockResolvedValue({ - name: 'www.example.com', - apexName: 'example.com' - }) - prismaService.customDomain.create.mockResolvedValue(customDomain) - await expect(resolver.customDomainCreate(input, ability)).rejects.toThrow( - 'user is not allowed to create custom domain' - ) - }) - }) - - describe('customDomainUpdate', () => { - it('should update a custom domain', async () => { - const input: CustomDomainUpdateInput = { - routeAllTeamJourneys: true, - journeyCollectionId: 'id' - } - prismaService.customDomain.findUnique.mockResolvedValueOnce( - customDomainWithUserTeam - ) - prismaService.customDomain.update.mockResolvedValueOnce(customDomain) - - expect( - await resolver.customDomainUpdate('customDomainId', input, ability) - ).toEqual(customDomain) - expect(prismaService.customDomain.update).toHaveBeenCalledWith({ - data: { - ...omit(input, 'journeyCollectionId'), - journeyCollection: { - connect: { id: input.journeyCollectionId } - } - }, - where: { id: 'customDomainId' } - }) - }) - - it('should handle null values', async () => { - const input: CustomDomainUpdateInput = { - routeAllTeamJourneys: null, - journeyCollectionId: null - } - prismaService.customDomain.findUnique.mockResolvedValueOnce( - customDomainWithUserTeam - ) - prismaService.customDomain.update.mockResolvedValueOnce(customDomain) - - expect( - await resolver.customDomainUpdate('customDomainId', input, ability) - ).toEqual(customDomain) - expect(prismaService.customDomain.update).toHaveBeenCalledWith({ - data: { - routeAllTeamJourneys: undefined, - journeyCollection: { - connect: { id: undefined } - } - }, - where: { id: 'customDomainId' } - }) - }) - - it('should handle not found', async () => { - const input: CustomDomainUpdateInput = { - routeAllTeamJourneys: true, - journeyCollectionId: 'id' - } - prismaService.customDomain.findUnique.mockResolvedValueOnce(null) - await expect( - resolver.customDomainUpdate('customDomainId', input, ability) - ).rejects.toThrow('custom domain not found') - }) - - it('should handle not allowed', async () => { - const input: CustomDomainUpdateInput = { - routeAllTeamJourneys: true, - journeyCollectionId: 'id' - } - prismaService.customDomain.findUnique.mockResolvedValueOnce(customDomain) - await expect( - resolver.customDomainUpdate('customDomainId', input, ability) - ).rejects.toThrow('user is not allowed to update custom domain') - }) - }) - - describe('customDomainDelete', () => { - beforeEach(() => { - prismaService.$transaction.mockImplementation( - async (callback) => await callback(prismaService) - ) - }) - - it('should delete a custom domain', async () => { - prismaService.customDomain.findUnique.mockResolvedValueOnce( - customDomainWithUserTeam - ) - prismaService.customDomain.delete.mockResolvedValueOnce(customDomain) - service.deleteVercelDomain.mockResolvedValue(true) - expect( - await resolver.customDomainDelete('customDomainId', ability) - ).toEqual(customDomainWithUserTeam) - expect(prismaService.customDomain.delete).toHaveBeenCalledWith({ - where: { id: 'customDomainId' } - }) - }) - - it('should handle not found', async () => { - prismaService.customDomain.findUnique.mockResolvedValueOnce(null) - await expect( - resolver.customDomainDelete('customDomainId', ability) - ).rejects.toThrow('custom domain not found') - }) - - it('should handle not allowed', async () => { - prismaService.customDomain.findUnique.mockResolvedValueOnce(customDomain) - await expect( - resolver.customDomainDelete('customDomainId', ability) - ).rejects.toThrow('user is not allowed to delete custom domain') - }) - }) - - describe('customDomainCheck', () => { - it('should return a custom domain check', async () => { - prismaService.customDomain.findUnique.mockResolvedValueOnce( - customDomainWithUserTeam - ) - service.checkVercelDomain.mockResolvedValue({ - configured: true, - verified: true - }) - expect( - await resolver.customDomainCheck('customDomainId', ability) - ).toEqual({ - configured: true, - verified: true - }) - }) - - it('should handle not found', async () => { - prismaService.customDomain.findUnique.mockResolvedValueOnce(null) - await expect( - resolver.customDomainCheck('customDomainId', ability) - ).rejects.toThrow('custom domain not found') - }) - - it('should handle not allowed', async () => { - prismaService.customDomain.findUnique.mockResolvedValueOnce(customDomain) - await expect( - resolver.customDomainCheck('customDomainId', ability) - ).rejects.toThrow('user is not allowed to check custom domain') - }) - }) - - describe('journeyCollection', () => { - it('should return a journey collection', async () => { - prismaService.journeyCollection.findFirst.mockResolvedValueOnce({ - id: 'id', - team: { - id: 'teamId' - } as unknown as Team, - title: 'title', - journeyCollectionJourneys: [ - { - id: 'id', - order: 1, - journey: { - id: 'id' - } as unknown as Journey - } - ] - } as unknown as JourneyCollection) - expect( - await resolver.journeyCollection({ - ...customDomain, - journeyCollectionId: 'id' - }) - ).toEqual({ - id: 'id', - team: { - id: 'teamId' - }, - title: 'title', - journeys: [ - { - id: 'id' - } - ] - }) - }) - - it('should handle null', async () => { - expect( - await resolver.journeyCollection({ - ...customDomain, - journeyCollectionId: null - }) - ).toBeNull() - }) - }) - - describe('team', () => { - it('should return a team', async () => { - const team: Team = { - id: 'id', - title: 'title', - publicTitle: 'publicTitle', - createdAt: new Date(), - updatedAt: new Date(), - plausibleToken: null - } - prismaService.team.findUnique.mockResolvedValue(team) - expect(await resolver.team(customDomain)).toEqual(team) - }) - }) -}) diff --git a/apis/api-journeys/src/app/modules/customDomain/customDomain.resolver.ts b/apis/api-journeys/src/app/modules/customDomain/customDomain.resolver.ts deleted file mode 100644 index a1cb33cc57c..00000000000 --- a/apis/api-journeys/src/app/modules/customDomain/customDomain.resolver.ts +++ /dev/null @@ -1,262 +0,0 @@ -import { subject } from '@casl/ability' -import { UseGuards } from '@nestjs/common' -import { - Args, - Mutation, - Parent, - Query, - ResolveField, - Resolver -} from '@nestjs/graphql' -import { GraphQLError } from 'graphql' -import omit from 'lodash/omit' - -import { - CustomDomain, - Journey, - JourneyCollection, - Prisma, - Team -} from '@core/prisma/journeys/client' - -import { - CustomDomainCheck, - CustomDomainCreateInput, - CustomDomainUpdateInput -} from '../../__generated__/graphql' -import { Action, AppAbility } from '../../lib/casl/caslFactory' -import { AppCaslGuard } from '../../lib/casl/caslGuard' -import { CaslAbility, CaslAccessible } from '../../lib/CaslAuthModule' -import { PrismaService } from '../../lib/prisma.service' -import { ERROR_PSQL_UNIQUE_CONSTRAINT_VIOLATED } from '../../lib/prismaErrors' -import { QrCodeService } from '../qrCode/qrCode.service' - -import { CustomDomainService } from './customDomain.service' - -@Resolver('CustomDomain') -export class CustomDomainResolver { - constructor( - private readonly prismaService: PrismaService, - private readonly customDomainService: CustomDomainService, - private readonly qrCodeService: QrCodeService - ) {} - - @Query() - @UseGuards(AppCaslGuard) - async customDomain( - @Args('id') id: string, - @CaslAbility() ability: AppAbility - ): Promise { - const customDomain = await this.prismaService.customDomain.findUnique({ - where: { id }, - include: { - team: { - include: { - userTeams: true, - journeys: { include: { userJourneys: true } } - } - } - } - }) - if (customDomain == null) - throw new GraphQLError('custom domain not found', { - extensions: { code: 'NOT_FOUND' } - }) - if (!ability.can(Action.Read, subject('CustomDomain', customDomain))) - throw new GraphQLError('user is not allowed to read custom domain', { - extensions: { code: 'FORBIDDEN' } - }) - return customDomain - } - - @Query() - @UseGuards(AppCaslGuard) - async customDomains( - @Args('teamId') teamId: string, - @CaslAccessible('CustomDomain') - accessibleCustomDomains: Prisma.CustomDomainWhereInput - ): Promise { - return await this.prismaService.customDomain.findMany({ - where: { AND: [accessibleCustomDomains, { teamId }] } - }) - } - - @Mutation() - @UseGuards(AppCaslGuard) - async customDomainCreate( - @Args('input') input: CustomDomainCreateInput, - @CaslAbility() ability: AppAbility - ): Promise { - if (!this.customDomainService.isDomainValid(input.name)) - throw new GraphQLError('custom domain has invalid domain name', { - extensions: { code: 'BAD_USER_INPUT' } - }) - - try { - return await this.prismaService.$transaction(async (tx) => { - const { apexName } = await this.customDomainService.createVercelDomain( - input.name - ) - const data: Prisma.CustomDomainCreateInput = { - ...omit(input, ['teamId', 'journeyCollectionId']), - id: input.id ?? undefined, - apexName, - team: { connect: { id: input.teamId } }, - routeAllTeamJourneys: input.routeAllTeamJourneys ?? undefined - } - if (input.journeyCollectionId != null) { - data.journeyCollection = { - connect: { id: input.journeyCollectionId } - } - } - const customDomain = await tx.customDomain.create({ - data, - include: { team: { include: { userTeams: true } } } - }) - if ( - !ability.can(Action.Create, subject('CustomDomain', customDomain)) - ) { - await this.customDomainService.deleteVercelDomain(customDomain) - throw new GraphQLError( - 'user is not allowed to create custom domain', - { - extensions: { code: 'FORBIDDEN' } - } - ) - } - - await this.qrCodeService.updateTeamShortLinks( - customDomain.teamId, - customDomain.name - ) - - return customDomain - }) - } catch (err) { - if (err.code === ERROR_PSQL_UNIQUE_CONSTRAINT_VIOLATED) { - throw new GraphQLError('custom domain already exists', { - extensions: { code: 'BAD_USER_INPUT' } - }) - } - throw err - } - } - - @Mutation() - @UseGuards(AppCaslGuard) - async customDomainUpdate( - @Args('id') id: string, - @Args('input') input: CustomDomainUpdateInput, - @CaslAbility() ability: AppAbility - ): Promise { - const customDomain = await this.prismaService.customDomain.findUnique({ - where: { id }, - include: { team: { include: { userTeams: true } } } - }) - if (customDomain == null) - throw new GraphQLError('custom domain not found', { - extensions: { code: 'NOT_FOUND' } - }) - if (!ability.can(Action.Update, subject('CustomDomain', customDomain))) - throw new GraphQLError('user is not allowed to update custom domain', { - extensions: { code: 'FORBIDDEN' } - }) - return await this.prismaService.customDomain.update({ - where: { id }, - data: { - routeAllTeamJourneys: input.routeAllTeamJourneys ?? undefined, - journeyCollection: { - connect: { id: input.journeyCollectionId ?? undefined } - } - } - }) - } - - @Mutation() - @UseGuards(AppCaslGuard) - async customDomainDelete( - @Args('id') id: string, - @CaslAbility() ability: AppAbility - ): Promise { - const customDomain = await this.prismaService.customDomain.findUnique({ - where: { id }, - include: { team: { include: { userTeams: true } } } - }) - if (customDomain == null) - throw new GraphQLError('custom domain not found', { - extensions: { code: 'NOT_FOUND' } - }) - if (!ability.can(Action.Delete, subject('CustomDomain', customDomain))) - throw new GraphQLError('user is not allowed to delete custom domain', { - extensions: { code: 'FORBIDDEN' } - }) - - await this.prismaService.$transaction(async (tx) => { - await this.qrCodeService.updateTeamShortLinks( - customDomain.teamId, - customDomain.name - ) - - await tx.customDomain.delete({ - where: { id } - }) - await this.customDomainService.deleteVercelDomain(customDomain) - }) - return customDomain - } - - @Mutation() - @UseGuards(AppCaslGuard) - async customDomainCheck( - @Args('id') id: string, - @CaslAbility() ability: AppAbility - ): Promise { - const customDomain = await this.prismaService.customDomain.findUnique({ - where: { id }, - include: { team: { include: { userTeams: true } } } - }) - if (customDomain == null) - throw new GraphQLError('custom domain not found', { - extensions: { code: 'NOT_FOUND' } - }) - if (!ability.can(Action.Manage, subject('CustomDomain', customDomain))) - throw new GraphQLError('user is not allowed to check custom domain', { - extensions: { code: 'FORBIDDEN' } - }) - return await this.customDomainService.checkVercelDomain(customDomain) - } - - @ResolveField() - async journeyCollection( - @Parent() customDomain: CustomDomain - ): Promise { - if (customDomain.journeyCollectionId == null) return null - - const result = await this.prismaService.journeyCollection.findFirst({ - where: { - customDomains: { some: { id: customDomain.id } } - }, - include: { - journeyCollectionJourneys: { - include: { journey: true }, - orderBy: { order: 'asc' } - }, - team: true - } - }) - - if (result == null) return null - - return { - ...omit(result, 'journeyCollectionJourneys'), - journeys: result.journeyCollectionJourneys.map(({ journey }) => journey) - } - } - - @ResolveField() - async team(@Parent() customDomain: CustomDomain): Promise { - return await this.prismaService.team.findUnique({ - where: { id: customDomain.teamId } - }) - } -} diff --git a/apis/api-journeys/src/app/modules/customDomain/customDomain.service.spec.ts b/apis/api-journeys/src/app/modules/customDomain/customDomain.service.spec.ts deleted file mode 100644 index 53ad9f5ed45..00000000000 --- a/apis/api-journeys/src/app/modules/customDomain/customDomain.service.spec.ts +++ /dev/null @@ -1,697 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing' -import { GraphQLError } from 'graphql' -import clone from 'lodash/clone' -import fetch, { Response } from 'node-fetch' - -import { CustomDomain } from '@core/prisma/journeys/client' - -import { - CustomDomainService, - VercelConfigDomainResponse, - VercelCreateDomainError, - VercelCreateDomainResponse, - VercelDomainResponse, - VercelVerifyDomainError, - VercelVerifyDomainResponse -} from './customDomain.service' - -jest.mock('node-fetch', () => { - const originalModule = jest.requireActual('node-fetch') - return { - __esModule: true, - ...originalModule, - default: jest.fn() - } -}) -const mockFetch = fetch as jest.MockedFunction - -describe('customDomainService', () => { - let service: CustomDomainService - - const customDomain = { - name: 'example.com' - } as unknown as CustomDomain - - class NoErrorThrownError extends Error {} - - const getError = async (call: () => unknown): Promise => { - try { - await call() - - throw new NoErrorThrownError() - } catch (error: unknown) { - return error as TError - } - } - - const originalEnv = clone(process.env) - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - providers: [CustomDomainService] - }).compile() - - service = module.get(CustomDomainService) - process.env = originalEnv - }) - - afterEach(() => { - process.env = originalEnv - jest.clearAllMocks() - }) - - describe('createVercelDomain', () => { - it('should return dummy when no environment variables', async () => { - expect(await service.createVercelDomain('name.com')).toEqual({ - name: 'name.com', - apexName: 'name.com' - }) - }) - - describe('when environment variables set', () => { - beforeEach(() => { - process.env = { - ...originalEnv, - VERCEL_TOKEN: 'token', - VERCEL_TEAM_ID: 'teamId', - VERCEL_JOURNEYS_PROJECT_ID: 'journeysProjectId' - } - }) - - it('should create a vercel domain', async () => { - const data: VercelCreateDomainResponse = { - name: 'name.com', - apexName: 'name.com' - } - mockFetch.mockResolvedValueOnce({ - ok: true, - status: 200, - json: async () => await Promise.resolve(data) - } as unknown as Response) - expect(await service.createVercelDomain('name.com')).toEqual(data) - expect(mockFetch).toHaveBeenCalledWith( - 'https://api.vercel.com/v10/projects/journeysProjectId/domains?teamId=teamId', - { - body: JSON.stringify({ - name: 'name.com' - }), - headers: { Authorization: 'Bearer token' }, - method: 'POST' - } - ) - }) - - it('should throw an error when 400 invalid_domain', async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => - await Promise.resolve({ - error: { - code: 'invalid_domain', - domain: 'invaliddomain', - message: 'Cannot add invalid domain name "invaliddomain".' - } - }), - status: 400 - } as unknown as Response) - - const error = await getError( - async () => await service.createVercelDomain('invaliddomain') - ) - expect(error).not.toBeInstanceOf(NoErrorThrownError) - expect(error.message).toBe( - 'Cannot add invalid domain name "invaliddomain".' - ) - expect(error).toHaveProperty('extensions', { - code: 'BAD_USER_INPUT', - vercelCode: 'invalid_domain' - }) - }) - - it('should throw an error when 409 domain_already_in_use', async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => - await Promise.resolve({ - error: { - code: 'domain_already_in_use', - projectId: 'journeysProjectId', - message: - "Cannot add name.com since it's already in use by your account." - } - }), - status: 409 - } as unknown as Response) - - const error = await getError( - async () => await service.createVercelDomain('name.com') - ) - expect(error).not.toBeInstanceOf(NoErrorThrownError) - expect(error.message).toBe( - "Cannot add name.com since it's already in use by your account." - ) - expect(error).toHaveProperty('extensions', { - code: 'CONFLICT', - vercelCode: 'domain_already_in_use' - }) - }) - - it('should throw an error when status not handled', async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => - await Promise.resolve({ - error: { - code: 'unauthorized', - message: 'You are not authorized.' - } - }), - status: 401 - } as unknown as Response) - - const error = await getError( - async () => await service.createVercelDomain('name.com') - ) - expect(error).not.toBeInstanceOf(NoErrorThrownError) - expect(error.message).toBe('vercel response not handled') - expect(error).toHaveProperty('extensions', { - code: 'INTERNAL_SERVER_ERROR' - }) - }) - }) - }) - - describe('deleteVercelDomain', () => { - it('should return dummy when no environment variables', async () => { - expect(await service.deleteVercelDomain(customDomain)).toBe(true) - }) - - describe('when environment variables set', () => { - beforeEach(() => { - process.env = { - ...originalEnv, - VERCEL_TOKEN: 'token', - VERCEL_TEAM_ID: 'teamId', - VERCEL_JOURNEYS_PROJECT_ID: 'journeysProjectId' - } - }) - - it('should return true', async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - status: 200, - json: async () => await Promise.resolve({}) - } as unknown as Response) - - expect(await service.deleteVercelDomain(customDomain)).toBe(true) - expect(mockFetch).toHaveBeenCalledWith( - 'https://api.vercel.com/v9/projects/journeysProjectId/domains/example.com?teamId=teamId', - { - headers: { Authorization: 'Bearer token' }, - method: 'DELETE' - } - ) - }) - - it('should return true when 404 not_found', async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => - await Promise.resolve({ - error: { - code: 'not_found', - message: - 'The domain "name.com" is not assigned to "project-name".' - } - }), - status: 404 - } as unknown as Response) - - expect(await service.deleteVercelDomain(customDomain)).toBe(true) - expect(mockFetch).toHaveBeenCalledWith( - 'https://api.vercel.com/v9/projects/journeysProjectId/domains/example.com?teamId=teamId', - { - headers: { Authorization: 'Bearer token' }, - method: 'DELETE' - } - ) - }) - - it('should throw an error when status not handled', async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => await Promise.resolve({}), - status: 401 - } as unknown as Response) - - const error = await getError( - async () => await service.deleteVercelDomain(customDomain) - ) - expect(error).not.toBeInstanceOf(NoErrorThrownError) - expect(error.message).toBe('vercel response not handled') - expect(error).toHaveProperty('extensions', { - code: 'INTERNAL_SERVER_ERROR' - }) - }) - }) - }) - - describe('checkVercelDomain', () => { - function mockCheckVercelDomainFetch( - name: string, - configData: VercelConfigDomainResponse, - domainData: VercelCreateDomainResponse | VercelCreateDomainError, - verifyData: VercelVerifyDomainResponse | VercelVerifyDomainError | null - ) { - return async (url: string) => { - switch (url) { - case `https://api.vercel.com/v6/domains/${name}/config?teamId=teamId`: - return await Promise.resolve({ - ok: true, - status: 200, - json: async () => await Promise.resolve(configData) - } as unknown as Response) - case `https://api.vercel.com/v9/projects/journeysProjectId/domains/${name}?teamId=teamId`: - return await Promise.resolve({ - ok: true, - status: 200, - json: async () => await Promise.resolve(domainData) - } as unknown as Response) - case `https://api.vercel.com/v9/projects/journeysProjectId/domains/${name}/verify?teamId=teamId`: - return await Promise.resolve({ - ok: true, - status: verifyData != null && 'error' in verifyData ? 400 : 200, - json: async () => await Promise.resolve(verifyData) - } as unknown as Response) - default: - return await Promise.resolve({ - ok: true, - status: 404, - json: async () => - await Promise.resolve({ error: { code: 'not_found' } }) - } as unknown as Response) - } - } - } - - it('should return dummy when no environment variables', async () => { - expect(await service.checkVercelDomain(customDomain)).toEqual({ - configured: true, - verified: true - }) - }) - - describe('when environment variables set', () => { - beforeEach(() => { - process.env = { - ...originalEnv, - VERCEL_TOKEN: 'token', - VERCEL_TEAM_ID: 'teamId', - VERCEL_JOURNEYS_PROJECT_ID: 'journeysProjectId' - } - }) - - describe('unverified because existing_project_domain', () => { - const domain = 'example.com' - const configData: VercelConfigDomainResponse = { - configuredBy: 'http', - nameservers: ['igor.ns.cloudflare.com', 'ainsley.ns.cloudflare.com'], - serviceType: 'external', - cnames: [], - aValues: ['172.67.132.66', '104.21.12.185'], - conflicts: [], - acceptedChallenges: ['http-01'], - misconfigured: false - } - const domainData: VercelDomainResponse = { - name: 'example.com', - apexName: 'example.com', - projectId: 'journeysProjectId', - redirect: null, - redirectStatusCode: null, - gitBranch: null, - updatedAt: 1712052568202, - createdAt: 1712052568202, - verified: false, - verification: [ - { - type: 'TXT', - domain: '_vercel.example.com', - value: 'vc-domain-verify=example.com,560d189717dfcd2b1ae0', - reason: 'pending_domain_verification' - } - ] - } - const verifyData: VercelVerifyDomainError = { - error: { - code: 'existing_project_domain', - message: - 'Domain example.com was added to a different project. Please complete verification to add it to this project instead.' - } - } - - beforeEach(() => { - mockFetch.mockImplementation( - mockCheckVercelDomainFetch( - domain, - configData, - domainData, - verifyData - ) - ) - }) - - it('should return configured and unverified', async () => { - expect(await service.checkVercelDomain(customDomain)).toEqual({ - configured: true, - verified: false, - verification: [ - { - type: 'TXT', - domain: '_vercel.example.com', - value: 'vc-domain-verify=example.com,560d189717dfcd2b1ae0', - reason: 'pending_domain_verification' - } - ], - verificationResponse: { - code: 'existing_project_domain', - message: - 'Domain example.com was added to a different project. Please complete verification to add it to this project instead.' - } - }) - }) - }) - - describe('unverified because missing_txt_record', () => { - const domain = 'www.example.com' - const configData: VercelConfigDomainResponse = { - configuredBy: null, - nameservers: [ - 'ns2.mytrafficmanagement.com', - 'ns1.mytrafficmanagement.com' - ], - serviceType: 'external', - cnames: [], - aValues: [ - '45.56.79.23', - '72.14.185.43', - '72.14.178.174', - '45.33.23.183', - '45.33.30.197', - '45.33.18.44', - '45.33.2.79', - '45.79.19.196', - '96.126.123.244', - '198.58.118.167', - '173.255.194.134', - '45.33.20.235' - ], - conflicts: [], - acceptedChallenges: [], - misconfigured: true - } - const domainData: VercelDomainResponse = { - name: 'www.example.com', - apexName: 'example.com', - projectId: 'journeysProjectId', - redirect: null, - redirectStatusCode: null, - gitBranch: null, - updatedAt: 1712008427374, - createdAt: 1712008427374, - verified: false, - verification: [ - { - type: 'TXT', - domain: '_vercel.example.com', - value: 'vc-domain-verify=www.example.com,e886cd36c2ae9464e6b5', - reason: 'pending_domain_verification' - } - ] - } - const verifyData: VercelVerifyDomainError = { - error: { - code: 'missing_txt_record', - message: - 'Domain _vercel.example.com is missing required TXT Record "vc-domain-verify=www.example.com,e886cd36c2ae9464e6b5"' - } - } - - beforeEach(() => { - mockFetch.mockImplementation( - mockCheckVercelDomainFetch( - domain, - configData, - domainData, - verifyData - ) - ) - }) - - it('should return misconfigured and unverified', async () => { - expect( - await service.checkVercelDomain({ - name: domain - } as unknown as CustomDomain) - ).toEqual({ - configured: false, - verified: false, - verification: [ - { - type: 'TXT', - domain: '_vercel.example.com', - value: 'vc-domain-verify=www.example.com,e886cd36c2ae9464e6b5', - reason: 'pending_domain_verification' - } - ], - verificationResponse: { - code: 'missing_txt_record', - message: - 'Domain _vercel.example.com is missing required TXT Record "vc-domain-verify=www.example.com,e886cd36c2ae9464e6b5"' - } - }) - }) - }) - - describe('unverified then verified', () => { - const domain = 'www.example.com' - const configData: VercelConfigDomainResponse = { - configuredBy: null, - nameservers: [ - 'ns2.mytrafficmanagement.com', - 'ns1.mytrafficmanagement.com' - ], - serviceType: 'external', - cnames: [], - aValues: [ - '45.56.79.23', - '72.14.185.43', - '72.14.178.174', - '45.33.23.183', - '45.33.30.197', - '45.33.18.44', - '45.33.2.79', - '45.79.19.196', - '96.126.123.244', - '198.58.118.167', - '173.255.194.134', - '45.33.20.235' - ], - conflicts: [], - acceptedChallenges: [], - misconfigured: true - } - const domainData: VercelDomainResponse = { - name: 'www.example.com', - apexName: 'example.com', - projectId: 'journeysProjectId', - redirect: null, - redirectStatusCode: null, - gitBranch: null, - updatedAt: 1712008427374, - createdAt: 1712008427374, - verified: false, - verification: [ - { - type: 'TXT', - domain: '_vercel.example.com', - value: 'vc-domain-verify=www.example.com,e886cd36c2ae9464e6b5', - reason: 'pending_domain_verification' - } - ] - } - const verifyData: VercelVerifyDomainResponse = { - name: 'www.example.com', - apexName: 'example.com', - projectId: 'journeysProjectId', - redirect: null, - redirectStatusCode: null, - gitBranch: null, - updatedAt: 1712005704408, - createdAt: 1712005704408, - verified: true - } - - beforeEach(() => { - mockFetch.mockImplementation( - mockCheckVercelDomainFetch( - domain, - configData, - domainData, - verifyData - ) - ) - }) - - it('should return misconfigured and verified', async () => { - expect( - await service.checkVercelDomain({ - name: domain - } as unknown as CustomDomain) - ).toEqual({ - configured: false, - verified: true - }) - }) - }) - - describe('misconfigured', () => { - const domain = 'www.example.com' - const configData: VercelConfigDomainResponse = { - configuredBy: null, - nameservers: ['ns63.domaincontrol.com', 'ns64.domaincontrol.com'], - serviceType: 'external', - cnames: [], - aValues: [], - conflicts: [], - acceptedChallenges: [], - misconfigured: true - } - const domainData: VercelDomainResponse = { - name: 'www.example.com', - apexName: 'example.com', - projectId: 'journeysProjectId', - redirect: null, - redirectStatusCode: null, - gitBranch: null, - updatedAt: 1712031870331, - createdAt: 1711138797591, - verified: true - } - const verifyData = null - - beforeEach(() => { - mockFetch.mockImplementation( - mockCheckVercelDomainFetch( - domain, - configData, - domainData, - verifyData - ) - ) - }) - - it('should return misconfigured and verified', async () => { - expect( - await service.checkVercelDomain({ - name: domain - } as unknown as CustomDomain) - ).toEqual({ - configured: false, - verified: true - }) - }) - }) - - describe('configured', () => { - const domain = 'example.com' - const configData: VercelConfigDomainResponse = { - configuredBy: 'http', - nameservers: [ - 'carlane.ns.cloudflare.com', - 'lochlan.ns.cloudflare.com' - ], - serviceType: 'external', - cnames: [], - aValues: ['172.67.134.126', '104.21.25.192'], - conflicts: [], - acceptedChallenges: ['http-01'], - misconfigured: false - } - const domainData: VercelDomainResponse = { - name: 'example.com', - apexName: 'example.com', - projectId: 'journeysProjectId', - redirect: null, - redirectStatusCode: null, - gitBranch: null, - updatedAt: 1711591718992, - createdAt: 1711591718992, - verified: true - } - const verifyData = null - - beforeEach(() => { - mockFetch.mockImplementation( - mockCheckVercelDomainFetch( - domain, - configData, - domainData, - verifyData - ) - ) - }) - - it('should return configured and verified', async () => { - expect( - await service.checkVercelDomain({ - name: domain - } as unknown as CustomDomain) - ).toEqual({ - configured: true, - verified: true - }) - }) - }) - }) - }) - - describe('isDomainValid', () => { - const VALID_DOMAINS = [ - 'www.google.com', - 'google.com', - 'mkyong123.com', - 'mkyong-info.com', - 'sub.mkyong.com', - 'sub.mkyong-info.com', - 'mkyong.com.au', - 'g.co', - 'mkyong.t.t.co' - ] - - VALID_DOMAINS.forEach((domain) => { - it(`should return true for valid domain ${domain}`, () => { - expect(service.isDomainValid(domain)).toBe(true) - }) - }) - - const INVALID_DOMAINS = [ - ['mkyong.t.t.c', 'Tld must between 2 and 6 long'], - ['mkyong,com', 'Comma is not allow'], - ['mkyong', 'No Tld'], - ['mkyong.123', 'Tld not allow digit'], - ['.com', 'Must start with [A-Za-z0-9]'], - ['mkyong.com/users', 'No Tld'], - ['-mkyong.com', 'Cannot begin with a hyphen -'], - ['mkyong-.com', 'Cannot end with a hyphen -'], - ['sub.-mkyong.com', 'Cannot begin with a hyphen -'], - ['sub.mkyong-.com', 'Cannot end with a hyphen -'] - ] - - INVALID_DOMAINS.forEach(([domain, reason]) => { - it(`should return false for invalid domain ${domain} because ${reason}`, () => { - expect(service.isDomainValid(domain)).toBe(false) - }) - }) - }) -}) diff --git a/apis/api-journeys/src/app/modules/customDomain/customDomain.service.ts b/apis/api-journeys/src/app/modules/customDomain/customDomain.service.ts deleted file mode 100644 index b15e41ce2c9..00000000000 --- a/apis/api-journeys/src/app/modules/customDomain/customDomain.service.ts +++ /dev/null @@ -1,235 +0,0 @@ -import { Injectable } from '@nestjs/common' -import { GraphQLError } from 'graphql' -import fetch from 'node-fetch' - -import { CustomDomain } from '@core/prisma/journeys/client' - -import { CustomDomainCheck } from '../../__generated__/graphql' - -export interface VercelCreateDomainResponse { - name: string - apexName: string -} -export interface VercelCreateDomainError { - error: { - code: string - message: string - } -} - -export interface VercelConfigDomainResponse { - configuredBy: string | null - nameservers: string[] - serviceType: string - cnames: string[] - aValues: string[] - conflicts: string[] - acceptedChallenges: string[] - misconfigured: boolean -} - -export interface VercelDomainResponse { - name: string - apexName: string - projectId: string - redirect: null - redirectStatusCode: null - gitBranch: null - updatedAt: number - createdAt: number - verified: boolean - verification?: [ - { - type: string - domain: string - value: string - reason: string - } - ] -} - -export interface VercelVerifyDomainResponse { - name: string - apexName: string - projectId: string - redirect: null - redirectStatusCode: null - gitBranch: null - updatedAt: number - createdAt: number - verified: boolean -} -export interface VercelVerifyDomainError { - error: { - code: string - message: string - } -} - -@Injectable() -export class CustomDomainService { - async createVercelDomain(name: string): Promise { - // Don't hit vercel outside of deployed environments - if (process.env.VERCEL_JOURNEYS_PROJECT_ID == null) - return { - name, - apexName: name - } - - const response = await fetch( - `https://api.vercel.com/v10/projects/${process.env.VERCEL_JOURNEYS_PROJECT_ID}/domains?teamId=${process.env.VERCEL_TEAM_ID}`, - { - body: JSON.stringify({ - name - }), - headers: { - Authorization: `Bearer ${process.env.VERCEL_TOKEN}` - }, - method: 'POST' - } - ) - - const data: VercelCreateDomainResponse | VercelCreateDomainError = - await response.json() - - if ('error' in data) { - switch (response.status) { - case 400: - throw new GraphQLError(data.error.message, { - extensions: { code: 'BAD_USER_INPUT', vercelCode: data.error.code } - }) - case 409: - throw new GraphQLError(data.error.message, { - extensions: { code: 'CONFLICT', vercelCode: data.error.code } - }) - default: - throw new GraphQLError('vercel response not handled', { - extensions: { code: 'INTERNAL_SERVER_ERROR' } - }) - } - } - return data - } - - async deleteVercelDomain({ name }: CustomDomain): Promise { - // Don't hit vercel outside of deployed environments - if (process.env.VERCEL_JOURNEYS_PROJECT_ID == null) return true - - const response = await fetch( - `https://api.vercel.com/v9/projects/${process.env.VERCEL_JOURNEYS_PROJECT_ID}/domains/${name}?teamId=${process.env.VERCEL_TEAM_ID}`, - { - headers: { - Authorization: `Bearer ${process.env.VERCEL_TOKEN}` - }, - method: 'DELETE' - } - ) - switch (response.status) { - case 200: - return true - case 404: - return true - default: - throw new GraphQLError('vercel response not handled', { - extensions: { code: 'INTERNAL_SERVER_ERROR' } - }) - } - } - - async checkVercelDomain({ name }: CustomDomain): Promise { - // Don't hit vercel outside of deployed environments - if (process.env.VERCEL_JOURNEYS_PROJECT_ID == null) - return { - configured: true, - verified: true - } - - const [configResponse, domainResponse] = await Promise.all([ - fetch( - `https://api.vercel.com/v6/domains/${name}/config?teamId=${process.env.VERCEL_TEAM_ID}`, - { - headers: { - Authorization: `Bearer ${process.env.VERCEL_TOKEN}` - }, - method: 'GET' - } - ), - fetch( - `https://api.vercel.com/v9/projects/${process.env.VERCEL_JOURNEYS_PROJECT_ID}/domains/${name}?teamId=${process.env.VERCEL_TEAM_ID}`, - { - headers: { - Authorization: `Bearer ${process.env.VERCEL_TOKEN}` - }, - method: 'GET' - } - ) - ]) - - if (domainResponse.status !== 200) - throw new GraphQLError('vercel domain response not handled', { - extensions: { code: 'INTERNAL_SERVER_ERROR' } - }) - - if (configResponse.status !== 200) - throw new GraphQLError('vercel config response not handled', { - extensions: { code: 'INTERNAL_SERVER_ERROR' } - }) - - const configData: VercelConfigDomainResponse = await configResponse.json() - const domainData: VercelDomainResponse = await domainResponse.json() - - let verifyData: - | VercelVerifyDomainResponse - | VercelVerifyDomainError - | null = null - if (!domainData.verified) { - const verifyResponse = await fetch( - `https://api.vercel.com/v9/projects/${process.env.VERCEL_JOURNEYS_PROJECT_ID}/domains/${name}/verify?teamId=${process.env.VERCEL_TEAM_ID}`, - { - headers: { - Authorization: `Bearer ${process.env.VERCEL_TOKEN}` - }, - method: 'POST' - } - ) - - verifyData = await verifyResponse.json() - - if ( - verifyResponse.status !== 200 && - (verifyData == null || - ('error' in verifyData && - !['existing_project_domain', 'missing_txt_record'].includes( - verifyData?.error?.code - ))) - ) - throw new GraphQLError('vercel verification response not handled', { - extensions: { code: 'INTERNAL_SERVER_ERROR' } - }) - } - - if (verifyData != null && 'verified' in verifyData && verifyData.verified) - return { - configured: !configData.misconfigured, - verified: true - } - - return { - configured: !configData.misconfigured, - verified: domainData.verified, - verification: domainData.verification, - verificationResponse: - verifyData != null && 'error' in verifyData - ? verifyData.error - : undefined - } - } - - isDomainValid(domain: string): boolean { - return ( - domain.match( - /^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9][a-z0-9-]{0,61}[a-z]$/ - ) != null - ) - } -} diff --git a/apis/api-journeys/src/app/modules/journey/journey.module.ts b/apis/api-journeys/src/app/modules/journey/journey.module.ts index b3e564c848a..d2dc1e92740 100644 --- a/apis/api-journeys/src/app/modules/journey/journey.module.ts +++ b/apis/api-journeys/src/app/modules/journey/journey.module.ts @@ -6,7 +6,6 @@ import { CaslAuthModule } from '../../lib/CaslAuthModule' import { DateTimeScalar } from '../../lib/dateTime/dateTime.provider' import { prismaServiceProvider } from '../../lib/prisma.service' import { BlockService } from '../block/block.service' -import { ChatButtonResolver } from '../chatButton/chatButton.resolver' import { QrCodeService } from '../qrCode/qrCode.service' import { JourneyResolver } from './journey.resolver' @@ -25,7 +24,6 @@ import { JourneyCustomizableService } from './journeyCustomizable.service' JourneyCustomizableService, BlockService, DateTimeScalar, - ChatButtonResolver, prismaServiceProvider, QrCodeService ] diff --git a/apps/journeys-admin/__generated__/CheckCustomDomain.ts b/apps/journeys-admin/__generated__/CheckCustomDomain.ts index c6ac67972dd..03651a782f5 100644 --- a/apps/journeys-admin/__generated__/CheckCustomDomain.ts +++ b/apps/journeys-admin/__generated__/CheckCustomDomain.ts @@ -23,23 +23,9 @@ export interface CheckCustomDomain_customDomainCheck_verificationResponse { export interface CheckCustomDomain_customDomainCheck { __typename: "CustomDomainCheck"; - /** - * Is the domain correctly configured in the DNS? - * If false, A Record and CNAME Record should be added by the user. - */ configured: boolean; - /** - * Does the domain belong to the team? - * If false, verification and verificationResponse will be populated. - */ verified: boolean; - /** - * Verification records to be added to the DNS to confirm ownership. - */ verification: CheckCustomDomain_customDomainCheck_verification[] | null; - /** - * Reasoning as to why verification is required. - */ verificationResponse: CheckCustomDomain_customDomainCheck_verificationResponse | null; }