From 1d4e2e6ed786ad3293adaf722c06b94172ebcd37 Mon Sep 17 00:00:00 2001 From: Mike Allison Date: Fri, 24 Apr 2026 22:57:05 +0000 Subject: [PATCH 01/26] feat: update customDomainUpdate mutation for modern journeys integration --- apis/api-gateway/schema.graphql | 2 +- apis/api-journeys-modern/schema.graphql | 1 + .../schema/customDomain/customDomain.acl.ts | 42 +++ .../customDomainUpdate.mutation.spec.ts | 239 ++++++++++++++++++ .../customDomainUpdate.mutation.ts | 59 +++++ .../src/schema/customDomain/index.ts | 1 + 6 files changed, 343 insertions(+), 1 deletion(-) create mode 100644 apis/api-journeys-modern/src/schema/customDomain/customDomain.acl.ts create mode 100644 apis/api-journeys-modern/src/schema/customDomain/customDomainUpdate.mutation.spec.ts create mode 100644 apis/api-journeys-modern/src/schema/customDomain/customDomainUpdate.mutation.ts diff --git a/apis/api-gateway/schema.graphql b/apis/api-gateway/schema.graphql index c8d065bf5f5..33bb7063de5 100644 --- a/apis/api-gateway/schema.graphql +++ b/apis/api-gateway/schema.graphql @@ -122,7 +122,7 @@ type Mutation @join__type(graph: API_ANALYTICS) @join__type(graph: 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) - customDomainUpdate(id: ID!, input: CustomDomainUpdateInput!) : CustomDomain! @join__field(graph: API_JOURNEYS) + customDomainUpdate(id: ID!, input: CustomDomainUpdateInput!) : CustomDomain! @join__field(graph: API_JOURNEYS_MODERN, override: "api-journeys") customDomainDelete(id: ID!) : CustomDomain! @join__field(graph: API_JOURNEYS) customDomainCheck(id: ID!) : CustomDomainCheck! @join__field(graph: API_JOURNEYS) """ diff --git a/apis/api-journeys-modern/schema.graphql b/apis/api-journeys-modern/schema.graphql index c6dcb20aca2..4148336ab7a 100644 --- a/apis/api-journeys-modern/schema.graphql +++ b/apis/api-journeys-modern/schema.graphql @@ -1520,6 +1520,7 @@ type Mutation { 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") + customDomainUpdate(id: ID!, input: CustomDomainUpdateInput!): CustomDomain! @override(from: "api-journeys") buttonClickEventCreate(input: ButtonClickEventCreateInput!): ButtonClickEvent! chatOpenEventCreate(input: ChatOpenEventCreateInput!): ChatOpenEvent! radioQuestionSubmissionEventCreate(input: RadioQuestionSubmissionEventCreateInput!): RadioQuestionSubmissionEvent! diff --git a/apis/api-journeys-modern/src/schema/customDomain/customDomain.acl.ts b/apis/api-journeys-modern/src/schema/customDomain/customDomain.acl.ts new file mode 100644 index 00000000000..640955924d1 --- /dev/null +++ b/apis/api-journeys-modern/src/schema/customDomain/customDomain.acl.ts @@ -0,0 +1,42 @@ +import { UserTeamRole } from '@core/prisma/journeys/client' +import { User as BaseUser } from '@core/yoga/firebaseClient' + +import { Action } from '../../lib/auth/ability' + +type User = BaseUser & { roles?: string[] } + +export function canAccessCustomDomain( + action: Action, + customDomain: { + team?: { + userTeams: Array<{ + userId: string + role: UserTeamRole + }> + } + }, + user: User +): boolean { + if (!customDomain.team?.userTeams) return false + + const userTeam = customDomain.team.userTeams.find( + (userTeam) => userTeam.userId === user.id + ) + + if ( + action === Action.Create || + action === Action.Update || + action === Action.Manage + ) { + return userTeam?.role === UserTeamRole.manager + } + + if (action === Action.Read) { + return ( + userTeam?.role === UserTeamRole.manager || + userTeam?.role === UserTeamRole.member + ) + } + + return false +} 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..a4c180b4d57 --- /dev/null +++ b/apis/api-journeys-modern/src/schema/customDomain/customDomainUpdate.mutation.spec.ts @@ -0,0 +1,239 @@ +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.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.customDomain.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: 'customDomainId' }, + data: { + routeAllTeamJourneys: undefined, + journeyCollection: { connect: { id: 'collectionId' } } + } + }) + ) + }) + + 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..032c57bf198 --- /dev/null +++ b/apis/api-journeys-modern/src/schema/customDomain/customDomainUpdate.mutation.ts @@ -0,0 +1,59 @@ +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 { CustomDomainRef } from './customDomain' +import { CustomDomainUpdateInput } from './inputs' + +builder.mutationField('customDomainUpdate', (t) => + t + .withAuth({ $any: { isAuthenticated: true, isAnonymous: true } }) + .prismaField({ + type: CustomDomainRef, + nullable: false, + override: { from: 'api-journeys' }, + 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' } } + ) + } + + return await prisma.customDomain.update({ + ...query, + where: { id }, + data: { + routeAllTeamJourneys: input.routeAllTeamJourneys ?? undefined, + journeyCollection: + input.journeyCollectionId != null + ? { connect: { id: input.journeyCollectionId } } + : undefined + } + }) + } + }) +) diff --git a/apis/api-journeys-modern/src/schema/customDomain/index.ts b/apis/api-journeys-modern/src/schema/customDomain/index.ts index aaa26655b63..8f1763aa4b4 100644 --- a/apis/api-journeys-modern/src/schema/customDomain/index.ts +++ b/apis/api-journeys-modern/src/schema/customDomain/index.ts @@ -1,2 +1,3 @@ import './customDomain' +import './customDomainUpdate.mutation' import './inputs' From 70849d5322b7543d805a4e1c1c33b5e00a74d1f0 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Fri, 24 Apr 2026 23:01:48 +0000 Subject: [PATCH 02/26] fix: lint issues --- .../src/schema/customDomain/customDomainUpdate.mutation.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/apis/api-journeys-modern/src/schema/customDomain/customDomainUpdate.mutation.ts b/apis/api-journeys-modern/src/schema/customDomain/customDomainUpdate.mutation.ts index 032c57bf198..42466732bac 100644 --- a/apis/api-journeys-modern/src/schema/customDomain/customDomainUpdate.mutation.ts +++ b/apis/api-journeys-modern/src/schema/customDomain/customDomainUpdate.mutation.ts @@ -5,8 +5,8 @@ import { prisma } from '@core/prisma/journeys/client' import { Action } from '../../lib/auth/ability' import { builder } from '../builder' -import { canAccessCustomDomain } from './customDomain.acl' import { CustomDomainRef } from './customDomain' +import { canAccessCustomDomain } from './customDomain.acl' import { CustomDomainUpdateInput } from './inputs' builder.mutationField('customDomainUpdate', (t) => @@ -34,9 +34,7 @@ builder.mutationField('customDomainUpdate', (t) => }) } - if ( - !canAccessCustomDomain(Action.Update, customDomain, context.user) - ) { + if (!canAccessCustomDomain(Action.Update, customDomain, context.user)) { throw new GraphQLError( 'user is not allowed to update custom domain', { extensions: { code: 'FORBIDDEN' } } From b5aa8c23ce53c987d19315afcdbf4207a6c73fb8 Mon Sep 17 00:00:00 2001 From: Mike Allison Date: Fri, 24 Apr 2026 23:28:09 +0000 Subject: [PATCH 03/26] feat: add customDomainDelete mutation for modern journeys integration and update access control --- apis/api-gateway/schema.graphql | 2 +- apis/api-journeys-modern/schema.graphql | 1 + .../schema/customDomain/customDomain.acl.ts | 1 + .../customDomain/customDomain.service.ts | 127 +++++++++++ .../customDomainDelete.mutation.spec.ts | 199 ++++++++++++++++++ .../customDomainDelete.mutation.ts | 55 +++++ .../src/schema/customDomain/index.ts | 1 + 7 files changed, 385 insertions(+), 1 deletion(-) create mode 100644 apis/api-journeys-modern/src/schema/customDomain/customDomain.service.ts create mode 100644 apis/api-journeys-modern/src/schema/customDomain/customDomainDelete.mutation.spec.ts create mode 100644 apis/api-journeys-modern/src/schema/customDomain/customDomainDelete.mutation.ts diff --git a/apis/api-gateway/schema.graphql b/apis/api-gateway/schema.graphql index 33bb7063de5..f5639d37113 100644 --- a/apis/api-gateway/schema.graphql +++ b/apis/api-gateway/schema.graphql @@ -123,7 +123,7 @@ type Mutation @join__type(graph: API_ANALYTICS) @join__type(graph: API_JOURNEYS chatButtonRemove(id: ID!) : ChatButton! @join__field(graph: API_JOURNEYS) customDomainCreate(input: CustomDomainCreateInput!) : CustomDomain! @join__field(graph: API_JOURNEYS) customDomainUpdate(id: ID!, input: CustomDomainUpdateInput!) : CustomDomain! @join__field(graph: API_JOURNEYS_MODERN, override: "api-journeys") - customDomainDelete(id: ID!) : CustomDomain! @join__field(graph: API_JOURNEYS) + customDomainDelete(id: ID!) : CustomDomain! @join__field(graph: API_JOURNEYS_MODERN, override: "api-journeys") customDomainCheck(id: ID!) : CustomDomainCheck! @join__field(graph: API_JOURNEYS) """ Creates a JourneyViewEvent, returns null if attempting to create another diff --git a/apis/api-journeys-modern/schema.graphql b/apis/api-journeys-modern/schema.graphql index 4148336ab7a..4823120972a 100644 --- a/apis/api-journeys-modern/schema.graphql +++ b/apis/api-journeys-modern/schema.graphql @@ -1520,6 +1520,7 @@ type Mutation { 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") + customDomainDelete(id: ID!): CustomDomain! @override(from: "api-journeys") customDomainUpdate(id: ID!, input: CustomDomainUpdateInput!): CustomDomain! @override(from: "api-journeys") buttonClickEventCreate(input: ButtonClickEventCreateInput!): ButtonClickEvent! chatOpenEventCreate(input: ChatOpenEventCreateInput!): ChatOpenEvent! diff --git a/apis/api-journeys-modern/src/schema/customDomain/customDomain.acl.ts b/apis/api-journeys-modern/src/schema/customDomain/customDomain.acl.ts index 640955924d1..6581592ca61 100644 --- a/apis/api-journeys-modern/src/schema/customDomain/customDomain.acl.ts +++ b/apis/api-journeys-modern/src/schema/customDomain/customDomain.acl.ts @@ -26,6 +26,7 @@ export function canAccessCustomDomain( if ( action === Action.Create || action === Action.Update || + action === Action.Delete || action === Action.Manage ) { return userTeam?.role === UserTeamRole.manager 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/customDomainDelete.mutation.spec.ts b/apis/api-journeys-modern/src/schema/customDomain/customDomainDelete.mutation.spec.ts new file mode 100644 index 00000000000..62d40a99243 --- /dev/null +++ b/apis/api-journeys-modern/src/schema/customDomain/customDomainDelete.mutation.spec.ts @@ -0,0 +1,199 @@ +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..7334dd21bbd --- /dev/null +++ b/apis/api-journeys-modern/src/schema/customDomain/customDomainDelete.mutation.ts @@ -0,0 +1,55 @@ +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, + override: { from: 'api-journeys' }, + 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/index.ts b/apis/api-journeys-modern/src/schema/customDomain/index.ts index 8f1763aa4b4..55baabd7692 100644 --- a/apis/api-journeys-modern/src/schema/customDomain/index.ts +++ b/apis/api-journeys-modern/src/schema/customDomain/index.ts @@ -1,3 +1,4 @@ import './customDomain' +import './customDomainDelete.mutation' import './customDomainUpdate.mutation' import './inputs' From 03112f87e83085122264e1158a5f2f8f2cd3906c Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sat, 25 Apr 2026 00:02:43 +0000 Subject: [PATCH 04/26] fix: lint issues --- .../customDomainDelete.mutation.spec.ts | 4 +- .../customDomainDelete.mutation.ts | 67 +++++++++---------- 2 files changed, 35 insertions(+), 36 deletions(-) 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 index 62d40a99243..63f0b8f6662 100644 --- a/apis/api-journeys-modern/src/schema/customDomain/customDomainDelete.mutation.spec.ts +++ b/apis/api-journeys-modern/src/schema/customDomain/customDomainDelete.mutation.spec.ts @@ -71,7 +71,9 @@ describe('customDomainDelete', () => { userId: mockUser.id, roles: [] } as any) - prismaMock.$transaction.mockImplementation(async (fn: any) => fn(prismaMock)) + prismaMock.$transaction.mockImplementation(async (fn: any) => + fn(prismaMock) + ) }) it('should delete custom domain when authorized', async () => { diff --git a/apis/api-journeys-modern/src/schema/customDomain/customDomainDelete.mutation.ts b/apis/api-journeys-modern/src/schema/customDomain/customDomainDelete.mutation.ts index 7334dd21bbd..2bae92f2d7e 100644 --- a/apis/api-journeys-modern/src/schema/customDomain/customDomainDelete.mutation.ts +++ b/apis/api-journeys-modern/src/schema/customDomain/customDomainDelete.mutation.ts @@ -13,43 +13,40 @@ import { } from './customDomain.service' builder.mutationField('customDomainDelete', (t) => - t - .withAuth({ $any: { isAuthenticated: true, isAnonymous: true } }) - .field({ - type: CustomDomainRef, - nullable: false, - override: { from: 'api-journeys' }, - 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 } } } + t.withAuth({ $any: { isAuthenticated: true, isAnonymous: true } }).field({ + type: CustomDomainRef, + nullable: false, + override: { from: 'api-journeys' }, + 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 (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) + if (!canAccessCustomDomain(Action.Delete, customDomain, context.user)) { + throw new GraphQLError('user is not allowed to delete custom domain', { + extensions: { code: 'FORBIDDEN' } }) - - return customDomain } - }) + + await prisma.$transaction(async (tx) => { + await updateTeamShortLinks(customDomain.teamId, customDomain.name) + await tx.customDomain.delete({ where: { id } }) + await deleteVercelDomain(customDomain) + }) + + return customDomain + } + }) ) From 95fcaf9f984be304499af097c91ad12a837c4144 Mon Sep 17 00:00:00 2001 From: Mike Allison Date: Wed, 29 Apr 2026 19:29:37 +0000 Subject: [PATCH 05/26] refactor(customDomainUpdate): simplify authentication and error handling logic --- .../customDomainUpdate.mutation.ts | 77 +++++++++---------- 1 file changed, 37 insertions(+), 40 deletions(-) diff --git a/apis/api-journeys-modern/src/schema/customDomain/customDomainUpdate.mutation.ts b/apis/api-journeys-modern/src/schema/customDomain/customDomainUpdate.mutation.ts index 42466732bac..3248b3f9f8f 100644 --- a/apis/api-journeys-modern/src/schema/customDomain/customDomainUpdate.mutation.ts +++ b/apis/api-journeys-modern/src/schema/customDomain/customDomainUpdate.mutation.ts @@ -10,48 +10,45 @@ import { canAccessCustomDomain } from './customDomain.acl' import { CustomDomainUpdateInput } from './inputs' builder.mutationField('customDomainUpdate', (t) => - t - .withAuth({ $any: { isAuthenticated: true, isAnonymous: true } }) - .prismaField({ - type: CustomDomainRef, - nullable: false, - override: { from: 'api-journeys' }, - 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 } } } + t.withAuth({ isAuthenticated: true }).prismaField({ + type: CustomDomainRef, + nullable: false, + override: { from: 'api-journeys' }, + 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 (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' } } - ) - } - - return await prisma.customDomain.update({ - ...query, - where: { id }, - data: { - routeAllTeamJourneys: input.routeAllTeamJourneys ?? undefined, - journeyCollection: - input.journeyCollectionId != null - ? { connect: { id: input.journeyCollectionId } } - : undefined - } + if (!canAccessCustomDomain(Action.Update, customDomain, context.user)) { + throw new GraphQLError('user is not allowed to update custom domain', { + extensions: { code: 'FORBIDDEN' } }) } - }) + + return await prisma.customDomain.update({ + ...query, + where: { id }, + data: { + routeAllTeamJourneys: input.routeAllTeamJourneys ?? undefined, + journeyCollection: + input.journeyCollectionId != null + ? { connect: { id: input.journeyCollectionId } } + : undefined + } + }) + } + }) ) From 5ebec924c279d73b0bd2ae7ebfb2324b50d28090 Mon Sep 17 00:00:00 2001 From: Mike Allison Date: Wed, 29 Apr 2026 20:21:30 +0000 Subject: [PATCH 06/26] fix: validate journeyCollectionId team ownership and support null disconnect Prevents cross-team association by verifying the journeyCollection belongs to the custom domain's team before connecting. Also distinguishes null (disconnect) from undefined (no change). Made-with: Cursor --- .../customDomainUpdate.mutation.ts | 35 ++++++++++++++++--- 1 file changed, 31 insertions(+), 4 deletions(-) diff --git a/apis/api-journeys-modern/src/schema/customDomain/customDomainUpdate.mutation.ts b/apis/api-journeys-modern/src/schema/customDomain/customDomainUpdate.mutation.ts index 3248b3f9f8f..7e1bf914008 100644 --- a/apis/api-journeys-modern/src/schema/customDomain/customDomainUpdate.mutation.ts +++ b/apis/api-journeys-modern/src/schema/customDomain/customDomainUpdate.mutation.ts @@ -38,15 +38,42 @@ builder.mutationField('customDomainUpdate', (t) => }) } + 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: - input.journeyCollectionId != null - ? { connect: { id: input.journeyCollectionId } } - : undefined + journeyCollection: journeyCollectionUpdate } }) } From 3dcd4fc0d5f6247171e0b9ec170ec1de510cef2c Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Wed, 29 Apr 2026 20:25:41 +0000 Subject: [PATCH 07/26] fix: lint issues --- .../customDomain/customDomainUpdate.mutation.ts | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/apis/api-journeys-modern/src/schema/customDomain/customDomainUpdate.mutation.ts b/apis/api-journeys-modern/src/schema/customDomain/customDomainUpdate.mutation.ts index 7e1bf914008..3076f1f8481 100644 --- a/apis/api-journeys-modern/src/schema/customDomain/customDomainUpdate.mutation.ts +++ b/apis/api-journeys-modern/src/schema/customDomain/customDomainUpdate.mutation.ts @@ -47,13 +47,12 @@ builder.mutationField('customDomainUpdate', (t) => if (input.journeyCollectionId == null) { journeyCollectionUpdate = { disconnect: true } } else { - const journeyCollection = - await prisma.journeyCollection.findFirst({ - where: { - id: input.journeyCollectionId, - teamId: customDomain.teamId - } - }) + const journeyCollection = await prisma.journeyCollection.findFirst({ + where: { + id: input.journeyCollectionId, + teamId: customDomain.teamId + } + }) if (journeyCollection == null) { throw new GraphQLError( From 5035f75515d40365e2804adce286c46df632623a Mon Sep 17 00:00:00 2001 From: Mike Allison Date: Wed, 29 Apr 2026 21:12:09 +0000 Subject: [PATCH 08/26] test(customDomainUpdate): add tests for journeyCollectionId handling and permissions - Implement tests to verify behavior when journeyCollectionId is null, ensuring proper disconnection. - Add validation for journeyCollectionId ownership, returning FORBIDDEN if it belongs to another team. - Ensure correct calls to the database mock for journeyCollection queries. --- .../customDomainUpdate.mutation.spec.ts | 75 +++++++++++++++++++ 1 file changed, 75 insertions(+) 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 index a4c180b4d57..45d60dbb191 100644 --- a/apis/api-journeys-modern/src/schema/customDomain/customDomainUpdate.mutation.spec.ts +++ b/apis/api-journeys-modern/src/schema/customDomain/customDomainUpdate.mutation.spec.ts @@ -106,6 +106,10 @@ describe('customDomainUpdate', () => { prismaMock.customDomain.findUnique.mockResolvedValue( mockCustomDomain as any ) + prismaMock.journeyCollection.findFirst.mockResolvedValue({ + id: 'collectionId', + teamId: 'teamId' + } as any) prismaMock.customDomain.update.mockResolvedValue({ ...mockCustomDomain, journeyCollectionId: 'collectionId' @@ -130,6 +134,9 @@ describe('customDomainUpdate', () => { } }) + expect(prismaMock.journeyCollection.findFirst).toHaveBeenCalledWith({ + where: { id: 'collectionId', teamId: 'teamId' } + }) expect(prismaMock.customDomain.update).toHaveBeenCalledWith( expect.objectContaining({ where: { id: 'customDomainId' }, @@ -141,6 +148,74 @@ describe('customDomainUpdate', () => { ) }) + 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) From 762b137239b013811d85ece6f3727257b68bbed1 Mon Sep 17 00:00:00 2001 From: Mike Allison Date: Wed, 29 Apr 2026 22:02:19 +0000 Subject: [PATCH 09/26] feat(customDomain): enhance custom domain check functionality - Updated the GraphQL schema to include the `customDomainCheck` mutation in both `api-gateway` and `api-journeys-modern`. - Introduced new types for `CustomDomainCheck`, `CustomDomainVerification`, and `CustomDomainVerificationResponse` in `api-journeys-modern`. - Implemented logic to verify domain configuration and verification status through Vercel API. - Ensured proper integration of the new mutation and types across relevant files. --- apis/api-gateway/schema.graphql | 8 +- apis/api-journeys-modern/schema.graphql | 26 ++ .../customDomainCheck.mutation.spec.ts | 243 ++++++++++++++++++ .../customDomainCheck.mutation.ts | 43 ++++ .../schema/customDomain/customDomainCheck.ts | 62 +++++ .../src/schema/customDomain/index.ts | 2 + .../src/schema/customDomain/service.ts | 138 ++++++++++ 7 files changed, 518 insertions(+), 4 deletions(-) create mode 100644 apis/api-journeys-modern/src/schema/customDomain/customDomainCheck.mutation.spec.ts create mode 100644 apis/api-journeys-modern/src/schema/customDomain/customDomainCheck.mutation.ts create mode 100644 apis/api-journeys-modern/src/schema/customDomain/customDomainCheck.ts diff --git a/apis/api-gateway/schema.graphql b/apis/api-gateway/schema.graphql index c7718425a98..b0f9efef238 100644 --- a/apis/api-gateway/schema.graphql +++ b/apis/api-gateway/schema.graphql @@ -124,7 +124,7 @@ type Mutation @join__type(graph: API_ANALYTICS) @join__type(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_MODERN, override: "api-journeys") customDomainDelete(id: ID!) : CustomDomain! @join__field(graph: API_JOURNEYS_MODERN, override: "api-journeys") - customDomainCheck(id: ID!) : CustomDomainCheck! @join__field(graph: API_JOURNEYS) + customDomainCheck(id: ID!) : CustomDomainCheck! @join__field(graph: API_JOURNEYS_MODERN, override: "api-journeys") """ Creates a JourneyViewEvent, returns null if attempting to create another JourneyViewEvent with the same userId, journeyId, and within the same 24hr @@ -1103,7 +1103,7 @@ type CustomDomain @join__type(graph: API_JOURNEYS) @join__type(graph: API_JOURN routeAllTeamJourneys: Boolean! } -type CustomDomainCheck @join__type(graph: API_JOURNEYS) { +type CustomDomainCheck @join__type(graph: API_JOURNEYS) @join__type(graph: API_JOURNEYS_MODERN) { """ Is the domain correctly configured in the DNS? If false, A Record and CNAME Record should be added by the user. @@ -1124,14 +1124,14 @@ type CustomDomainCheck @join__type(graph: API_JOURNEYS) { verificationResponse: CustomDomainVerificationResponse } -type CustomDomainVerification @join__type(graph: API_JOURNEYS) { +type CustomDomainVerification @join__type(graph: API_JOURNEYS) @join__type(graph: API_JOURNEYS_MODERN) { type: String! domain: String! value: String! reason: String! } -type CustomDomainVerificationResponse @join__type(graph: API_JOURNEYS) { +type CustomDomainVerificationResponse @join__type(graph: API_JOURNEYS) @join__type(graph: API_JOURNEYS_MODERN) { code: String! message: String! } diff --git a/apis/api-journeys-modern/schema.graphql b/apis/api-journeys-modern/schema.graphql index 663aa2eb54f..2f3dd1165ee 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. """ @@ -1520,6 +1545,7 @@ type Mutation { 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") + customDomainCheck(id: ID!): CustomDomainCheck! @override(from: "api-journeys") customDomainDelete(id: ID!): CustomDomain! @override(from: "api-journeys") customDomainUpdate(id: ID!, input: CustomDomainUpdateInput!): CustomDomain! @override(from: "api-journeys") customDomainCreate(input: CustomDomainCreateInput!): CustomDomain! @override(from: "api-journeys") 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..6c32798c500 --- /dev/null +++ b/apis/api-journeys-modern/src/schema/customDomain/customDomainCheck.mutation.ts @@ -0,0 +1,43 @@ +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, + override: { from: 'api-journeys' }, + 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..10d58004af4 --- /dev/null +++ b/apis/api-journeys-modern/src/schema/customDomain/customDomainCheck.ts @@ -0,0 +1,62 @@ +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/index.ts b/apis/api-journeys-modern/src/schema/customDomain/index.ts index 907d7034021..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,6 @@ import './customDomain' +import './customDomainCheck' +import './customDomainCheck.mutation' import './customDomainDelete.mutation' import './customDomainUpdate.mutation' import './customDomainCreate.mutation' 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 + } +} From 0f6d48915b6dd99651578c551b25070a6eb8c7f1 Mon Sep 17 00:00:00 2001 From: Mike Allison Date: Wed, 29 Apr 2026 22:28:07 +0000 Subject: [PATCH 10/26] refactor(customDomain): remove custom domain functionality from api-journeys - Deleted custom domain related types, mutations, and queries from the GraphQL schema in `api-journeys`. - Removed associated resolver, service, and ACL files for custom domains. - Updated the app module to exclude the CustomDomainModule. - Cleaned up related tests and access control logic to streamline the codebase. --- apis/api-gateway/schema.graphql | 656 ++++++++--------- apis/api-journeys/schema.graphql | 189 ++--- .../src/app/__generated__/graphql.ts | 151 ++-- apis/api-journeys/src/app/app.module.ts | 2 - .../app/lib/casl/caslFactory/caslFactory.ts | 2 - .../customDomain/customDomain.acl.spec.ts | 244 ------ .../modules/customDomain/customDomain.acl.ts | 52 -- .../modules/customDomain/customDomain.graphql | 58 -- .../customDomain/customDomain.module.ts | 22 - .../customDomain.resolver.spec.ts | 438 ----------- .../customDomain/customDomain.resolver.ts | 262 ------- .../customDomain/customDomain.service.spec.ts | 697 ------------------ .../customDomain/customDomain.service.ts | 235 ------ 13 files changed, 439 insertions(+), 2569 deletions(-) delete mode 100644 apis/api-journeys/src/app/modules/customDomain/customDomain.acl.spec.ts delete mode 100644 apis/api-journeys/src/app/modules/customDomain/customDomain.acl.ts delete mode 100644 apis/api-journeys/src/app/modules/customDomain/customDomain.module.ts delete mode 100644 apis/api-journeys/src/app/modules/customDomain/customDomain.resolver.spec.ts delete mode 100644 apis/api-journeys/src/app/modules/customDomain/customDomain.resolver.ts delete mode 100644 apis/api-journeys/src/app/modules/customDomain/customDomain.service.spec.ts delete mode 100644 apis/api-journeys/src/app/modules/customDomain/customDomain.service.ts diff --git a/apis/api-gateway/schema.graphql b/apis/api-gateway/schema.graphql index b0f9efef238..67b3061d3d1 100644 --- a/apis/api-gateway/schema.graphql +++ b/apis/api-gateway/schema.graphql @@ -121,10 +121,6 @@ type Mutation @join__type(graph: API_ANALYTICS) @join__type(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_MODERN, override: "api-journeys") - customDomainDelete(id: ID!) : CustomDomain! @join__field(graph: API_JOURNEYS_MODERN, override: "api-journeys") - customDomainCheck(id: ID!) : CustomDomainCheck! @join__field(graph: API_JOURNEYS_MODERN, override: "api-journeys") """ Creates a JourneyViewEvent, returns null if attempting to create another JourneyViewEvent with the same userId, journeyId, and within the same 24hr @@ -374,6 +370,10 @@ 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) + customDomainCheck(id: ID!) : CustomDomainCheck! @join__field(graph: API_JOURNEYS_MODERN, override: "api-journeys") + customDomainDelete(id: ID!) : CustomDomain! @join__field(graph: API_JOURNEYS_MODERN, override: "api-journeys") + customDomainUpdate(id: ID!, input: CustomDomainUpdateInput!) : CustomDomain! @join__field(graph: API_JOURNEYS_MODERN, override: "api-journeys") + customDomainCreate(input: CustomDomainCreateInput!) : CustomDomain! @join__field(graph: API_JOURNEYS_MODERN, override: "api-journeys") 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) @join__type(graph: API_JOURNEYS_MODERN) { - """ - 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) @join__type(graph: API_JOURNEYS_MODERN) { - type: String! - domain: String! - value: String! - reason: String! -} - -type CustomDomainVerificationResponse @join__type(graph: API_JOURNEYS) @join__type(graph: API_JOURNEYS_MODERN) { - 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! """ @@ -1779,54 +1500,300 @@ type VideoCollapseEvent implements Event @join__type(graph: API_JOURNEYS) @join """ value: String """ - duration of the video played when the VideoCollapseEvent is triggered + duration of the video played when the VideoCollapseEvent is triggered + """ + position: Float + """ + 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 """ - position: Float + 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, override: "api-journeys") + customDomain(id: ID!) : CustomDomain! @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) """ - source of the video (based on the source in the value field) + 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) @@ -3993,19 +3979,6 @@ input ChatButtonUpdateInput @join__type(graph: API_JOURNEYS) @join__type(graph: 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) @@ -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/schema.graphql b/apis/api-journeys/schema.graphql index 90ce1b49748..4b4edd82d79 100644 --- a/apis/api-journeys/schema.graphql +++ b/apis/api-journeys/schema.graphql @@ -222,10 +222,6 @@ type Mutation { 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 @@ -922,125 +918,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 +1590,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..9e8ce783a71 100644 --- a/apis/api-journeys/src/app/__generated__/graphql.ts +++ b/apis/api-journeys/src/app/__generated__/graphql.ts @@ -331,19 +331,6 @@ export class ChatButtonUpdateInput { 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; @@ -791,14 +778,6 @@ export abstract class IMutation { 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 +1151,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 +1335,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/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 - ) - } -} From f73e216c211811241512284fc3d323c5c1507405 Mon Sep 17 00:00:00 2001 From: Mike Allison Date: Wed, 29 Apr 2026 22:50:21 +0000 Subject: [PATCH 11/26] refactor(customDomain): standardize custom domain fields in GraphQL schema - Removed override references for custom domain types and mutations in `api-journeys-modern` and `api-gateway`. - Updated the GraphQL schema to ensure consistency across custom domain functionalities. - Cleaned up related query and mutation files to align with the new schema structure. --- apis/api-gateway/schema.graphql | 12 ++++++------ apis/api-journeys-modern/schema.graphql | 12 ++++++------ .../src/schema/customDomain/customDomain.query.ts | 1 - .../customDomain/customDomainCheck.mutation.ts | 1 - .../customDomain/customDomainCreate.mutation.ts | 1 - .../customDomain/customDomainDelete.mutation.ts | 1 - .../customDomain/customDomainUpdate.mutation.ts | 1 - .../src/schema/customDomain/customDomains.query.ts | 1 - 8 files changed, 12 insertions(+), 18 deletions(-) diff --git a/apis/api-gateway/schema.graphql b/apis/api-gateway/schema.graphql index 67b3061d3d1..e7d34d513f6 100644 --- a/apis/api-gateway/schema.graphql +++ b/apis/api-gateway/schema.graphql @@ -370,10 +370,10 @@ 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) - customDomainCheck(id: ID!) : CustomDomainCheck! @join__field(graph: API_JOURNEYS_MODERN, override: "api-journeys") - customDomainDelete(id: ID!) : CustomDomain! @join__field(graph: API_JOURNEYS_MODERN, override: "api-journeys") - customDomainUpdate(id: ID!, input: CustomDomainUpdateInput!) : CustomDomain! @join__field(graph: API_JOURNEYS_MODERN, override: "api-journeys") - customDomainCreate(input: CustomDomainCreateInput!) : CustomDomain! @join__field(graph: API_JOURNEYS_MODERN, override: "api-journeys") + 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) @@ -1623,8 +1623,8 @@ type Query @join__type(graph: API_ANALYTICS) @join__type(graph: API_JOURNEYS) 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, override: "api-journeys") - customDomain(id: ID!) : CustomDomain! @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) diff --git a/apis/api-journeys-modern/schema.graphql b/apis/api-journeys-modern/schema.graphql index 2f3dd1165ee..5c58d43ea05 100644 --- a/apis/api-journeys-modern/schema.graphql +++ b/apis/api-journeys-modern/schema.graphql @@ -1545,10 +1545,10 @@ type Mutation { 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") - customDomainCheck(id: ID!): CustomDomainCheck! @override(from: "api-journeys") - customDomainDelete(id: ID!): CustomDomain! @override(from: "api-journeys") - customDomainUpdate(id: ID!, input: CustomDomainUpdateInput!): CustomDomain! @override(from: "api-journeys") - customDomainCreate(input: CustomDomainCreateInput!): CustomDomain! @override(from: "api-journeys") + 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! radioQuestionSubmissionEventCreate(input: RadioQuestionSubmissionEventCreateInput!): RadioQuestionSubmissionEvent! @@ -1899,8 +1899,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/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/customDomainCheck.mutation.ts b/apis/api-journeys-modern/src/schema/customDomain/customDomainCheck.mutation.ts index 6c32798c500..656a2adf763 100644 --- a/apis/api-journeys-modern/src/schema/customDomain/customDomainCheck.mutation.ts +++ b/apis/api-journeys-modern/src/schema/customDomain/customDomainCheck.mutation.ts @@ -13,7 +13,6 @@ builder.mutationField('customDomainCheck', (t) => t.withAuth({ $any: { isAuthenticated: true, isAnonymous: true } }).field({ type: CustomDomainCheck, nullable: false, - override: { from: 'api-journeys' }, args: { id: t.arg({ type: 'ID', required: true }) }, 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.ts b/apis/api-journeys-modern/src/schema/customDomain/customDomainDelete.mutation.ts index 2bae92f2d7e..77d2b07beb2 100644 --- a/apis/api-journeys-modern/src/schema/customDomain/customDomainDelete.mutation.ts +++ b/apis/api-journeys-modern/src/schema/customDomain/customDomainDelete.mutation.ts @@ -16,7 +16,6 @@ builder.mutationField('customDomainDelete', (t) => t.withAuth({ $any: { isAuthenticated: true, isAnonymous: true } }).field({ 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/customDomainUpdate.mutation.ts b/apis/api-journeys-modern/src/schema/customDomain/customDomainUpdate.mutation.ts index 3076f1f8481..6212e034b1e 100644 --- a/apis/api-journeys-modern/src/schema/customDomain/customDomainUpdate.mutation.ts +++ b/apis/api-journeys-modern/src/schema/customDomain/customDomainUpdate.mutation.ts @@ -13,7 +13,6 @@ builder.mutationField('customDomainUpdate', (t) => t.withAuth({ isAuthenticated: true }).prismaField({ type: CustomDomainRef, nullable: false, - override: { from: 'api-journeys' }, args: { id: t.arg({ type: 'ID', required: true }), input: t.arg({ type: CustomDomainUpdateInput, required: true }) 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 }) }, From 13ad2075f00c5cfc2c65d23cc75630d83aea24bc Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Wed, 29 Apr 2026 22:55:14 +0000 Subject: [PATCH 12/26] fix: lint issues --- .../src/schema/customDomain/customDomainCheck.ts | 5 +++-- .../__generated__/CheckCustomDomain.ts | 14 -------------- 2 files changed, 3 insertions(+), 16 deletions(-) diff --git a/apis/api-journeys-modern/src/schema/customDomain/customDomainCheck.ts b/apis/api-journeys-modern/src/schema/customDomain/customDomainCheck.ts index 10d58004af4..0dbd55fc2b5 100644 --- a/apis/api-journeys-modern/src/schema/customDomain/customDomainCheck.ts +++ b/apis/api-journeys-modern/src/schema/customDomain/customDomainCheck.ts @@ -14,8 +14,9 @@ interface VerificationResponseShape { message: string } -const CustomDomainVerification = - builder.objectRef('CustomDomainVerification') +const CustomDomainVerification = builder.objectRef( + 'CustomDomainVerification' +) builder.objectType(CustomDomainVerification, { shareable: true, 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; } From e78fc43db93ed0e6458bb8a44e2f46d99b36d376 Mon Sep 17 00:00:00 2001 From: Mike Allison Date: Wed, 29 Apr 2026 22:59:28 +0000 Subject: [PATCH 13/26] refactor(chatButton): remove create and update mutations from GraphQL schema - Deleted `ChatButtonCreateInput` and `ChatButtonUpdateInput` types from the GraphQL schema. - Removed associated `chatButtonCreate` and `chatButtonUpdate` mutations from the resolver and schema. - Cleaned up related tests to reflect the removal of chat button creation and update functionalities. --- .../src/app/__generated__/graphql.ts | 15 --- .../app/modules/chatButton/chatButton.graphql | 17 --- .../chatButton/chatButton.resolver.spec.ts | 122 ------------------ .../modules/chatButton/chatButton.resolver.ts | 46 ------- 4 files changed, 200 deletions(-) diff --git a/apis/api-journeys/src/app/__generated__/graphql.ts b/apis/api-journeys/src/app/__generated__/graphql.ts index 9e8ce783a71..9c7bfaad5bd 100644 --- a/apis/api-journeys/src/app/__generated__/graphql.ts +++ b/apis/api-journeys/src/app/__generated__/graphql.ts @@ -320,17 +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 JourneyViewEventCreateInput { id?: Nullable; journeyId: string; @@ -772,10 +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 journeyViewEventCreate(input: JourneyViewEventCreateInput): Nullable | Promise>; diff --git a/apis/api-journeys/src/app/modules/chatButton/chatButton.graphql b/apis/api-journeys/src/app/modules/chatButton/chatButton.graphql index 99c91c5f3a5..ff95bc669d7 100644 --- a/apis/api-journeys/src/app/modules/chatButton/chatButton.graphql +++ b/apis/api-journeys/src/app/modules/chatButton/chatButton.graphql @@ -9,23 +9,6 @@ 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 index 3425ba18669..a6fc0c94ecd 100644 --- a/apis/api-journeys/src/app/modules/chatButton/chatButton.resolver.spec.ts +++ b/apis/api-journeys/src/app/modules/chatButton/chatButton.resolver.spec.ts @@ -1,7 +1,6 @@ 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' @@ -20,14 +19,6 @@ describe('ChatButtonResolver', () => { 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: [ @@ -44,128 +35,15 @@ describe('ChatButtonResolver', () => { 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) diff --git a/apis/api-journeys/src/app/modules/chatButton/chatButton.resolver.ts b/apis/api-journeys/src/app/modules/chatButton/chatButton.resolver.ts index 304f2d04dc0..4c70d931695 100644 --- a/apis/api-journeys/src/app/modules/chatButton/chatButton.resolver.ts +++ b/apis/api-journeys/src/app/modules/chatButton/chatButton.resolver.ts @@ -2,10 +2,6 @@ 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' @@ -16,48 +12,6 @@ export class ChatButtonResolver { 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({ From 8563e0894b33aa1bc58f30ca7f8f492837db1f43 Mon Sep 17 00:00:00 2001 From: Mike Allison Date: Wed, 29 Apr 2026 23:04:17 +0000 Subject: [PATCH 14/26] refactor(chatButton): update chatButtonRemove mutation for consistency - Changed the `chatButtonRemove` mutation in `api-gateway` to use the `API_JOURNEYS_MODERN` graph. - Added the `chatButtonRemove` mutation in `api-journeys-modern` with an override from `api-journeys`. - Updated the import structure in the chatButton schema to include the new mutation. --- apis/api-gateway/schema.graphql | 2 +- apis/api-journeys-modern/schema.graphql | 1 + .../chatButtonRemove.mutation.spec.ts | 142 ++++++++++++++++++ .../chatButton/chatButtonRemove.mutation.ts | 31 ++++ .../src/schema/chatButton/index.ts | 1 + 5 files changed, 176 insertions(+), 1 deletion(-) create mode 100644 apis/api-journeys-modern/src/schema/chatButton/chatButtonRemove.mutation.spec.ts create mode 100644 apis/api-journeys-modern/src/schema/chatButton/chatButtonRemove.mutation.ts diff --git a/apis/api-gateway/schema.graphql b/apis/api-gateway/schema.graphql index e7d34d513f6..3f5ff61e392 100644 --- a/apis/api-gateway/schema.graphql +++ b/apis/api-gateway/schema.graphql @@ -120,7 +120,7 @@ type Mutation @join__type(graph: API_ANALYTICS) @join__type(graph: API_JOURNEYS 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) + chatButtonRemove(id: ID!) : ChatButton! @join__field(graph: API_JOURNEYS_MODERN, override: "api-journeys") """ Creates a JourneyViewEvent, returns null if attempting to create another JourneyViewEvent with the same userId, journeyId, and within the same 24hr diff --git a/apis/api-journeys-modern/schema.graphql b/apis/api-journeys-modern/schema.graphql index 5c58d43ea05..7716ebfce50 100644 --- a/apis/api-journeys-modern/schema.graphql +++ b/apis/api-journeys-modern/schema.graphql @@ -1544,6 +1544,7 @@ type Mutation { 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") + chatButtonRemove(id: ID!): ChatButton! @override(from: "api-journeys") chatButtonUpdate(id: ID!, journeyId: ID!, input: ChatButtonUpdateInput!): ChatButton! @override(from: "api-journeys") customDomainCheck(id: ID!): CustomDomainCheck! customDomainDelete(id: ID!): CustomDomain! 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..5be6b402b95 --- /dev/null +++ b/apis/api-journeys-modern/src/schema/chatButton/chatButtonRemove.mutation.ts @@ -0,0 +1,31 @@ +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, + override: { from: 'api-journeys' }, + 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/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' From b02abf53cd8f90a0ff1730722e18755fcc69a39b Mon Sep 17 00:00:00 2001 From: Mike Allison Date: Wed, 29 Apr 2026 23:29:26 +0000 Subject: [PATCH 15/26] refactor(chatButton): reorganize chat button mutations and inputs - Moved `chatButtonCreate`, `chatButtonUpdate`, and `chatButtonRemove` mutations to the `api-gateway` schema under `API_JOURNEYS_MODERN`. - Updated `ChatButtonCreateInput` and `ChatButtonUpdateInput` types to be specific to `API_JOURNEYS_MODERN`. - Removed the `chatButtonRemove` mutation from the `api-journeys` schema and its resolver, along with associated tests. - Cleaned up the import structure in the journey module to reflect these changes. --- apis/api-gateway/schema.graphql | 28 +++++----- apis/api-journeys/schema.graphql | 14 ----- .../src/app/__generated__/graphql.ts | 2 - .../app/modules/chatButton/chatButton.graphql | 4 -- .../chatButton/chatButton.resolver.spec.ts | 56 ------------------- .../modules/chatButton/chatButton.resolver.ts | 25 --------- .../src/app/modules/journey/journey.module.ts | 2 - 7 files changed, 14 insertions(+), 117 deletions(-) delete mode 100644 apis/api-journeys/src/app/modules/chatButton/chatButton.resolver.spec.ts delete mode 100644 apis/api-journeys/src/app/modules/chatButton/chatButton.resolver.ts diff --git a/apis/api-gateway/schema.graphql b/apis/api-gateway/schema.graphql index 3f5ff61e392..7137278c995 100644 --- a/apis/api-gateway/schema.graphql +++ b/apis/api-gateway/schema.graphql @@ -118,9 +118,6 @@ 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_MODERN, override: "api-journeys") """ Creates a JourneyViewEvent, returns null if attempting to create another JourneyViewEvent with the same userId, journeyId, and within the same 24hr @@ -370,6 +367,9 @@ 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, override: "api-journeys") + chatButtonRemove(id: ID!) : 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") 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) @@ -3968,17 +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 JourneyViewEventCreateInput @join__type(graph: API_JOURNEYS) @join__type(graph: API_JOURNEYS_MODERN) { """ ID should be unique Event UUID (Provided for optimistic mutation result matching) @@ -4608,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) diff --git a/apis/api-journeys/schema.graphql b/apis/api-journeys/schema.graphql index 4b4edd82d79..ecbb3d92d58 100644 --- a/apis/api-journeys/schema.graphql +++ b/apis/api-journeys/schema.graphql @@ -219,9 +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! """ Creates a JourneyViewEvent, returns null if attempting to create another @@ -896,17 +893,6 @@ type ChatButton customizable: Boolean } -input ChatButtonCreateInput { - link: String - platform: MessagePlatform -} - -input ChatButtonUpdateInput { - link: String - platform: MessagePlatform - customizable: Boolean -} - type CustomDomain @shareable { diff --git a/apis/api-journeys/src/app/__generated__/graphql.ts b/apis/api-journeys/src/app/__generated__/graphql.ts index 9c7bfaad5bd..2043e1457c1 100644 --- a/apis/api-journeys/src/app/__generated__/graphql.ts +++ b/apis/api-journeys/src/app/__generated__/graphql.ts @@ -761,8 +761,6 @@ export abstract class IMutation { abstract blockRestore(id: string): Block[] | Promise; - abstract chatButtonRemove(id: string): ChatButton | Promise; - abstract journeyViewEventCreate(input: JourneyViewEventCreateInput): Nullable | Promise>; abstract stepViewEventCreate(input: StepViewEventCreateInput): StepViewEvent | Promise; diff --git a/apis/api-journeys/src/app/modules/chatButton/chatButton.graphql b/apis/api-journeys/src/app/modules/chatButton/chatButton.graphql index ff95bc669d7..11d2737cd57 100644 --- a/apis/api-journeys/src/app/modules/chatButton/chatButton.graphql +++ b/apis/api-journeys/src/app/modules/chatButton/chatButton.graphql @@ -8,7 +8,3 @@ type ChatButton @shareable { extend type Journey { chatButtons: [ChatButton!]! } - -extend type Mutation { - 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 a6fc0c94ecd..00000000000 --- a/apis/api-journeys/src/app/modules/chatButton/chatButton.resolver.spec.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing' -import { DeepMockProxy, mockDeep } from 'jest-mock-extended' - -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 - } - - 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 - }) - - it('should delete an existing ChatButton', async () => { - prismaService.chatButton.delete = jest.fn().mockReturnValue(chatButton) - - const result = await resolver.chatButtonRemove('1') - expect(result).toEqual(chatButton) - }) - - 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 4c70d931695..00000000000 --- a/apis/api-journeys/src/app/modules/chatButton/chatButton.resolver.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { Args, Mutation, Resolver } from '@nestjs/graphql' - -import { ChatButton } from '@core/prisma/journeys/client' - -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 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/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 ] From 74f90a7b60499e0e07115dcd95910cadeae709af Mon Sep 17 00:00:00 2001 From: Mike Allison Date: Wed, 29 Apr 2026 23:31:46 +0000 Subject: [PATCH 16/26] refactor(chatButton): remove override references for chat button mutations - Eliminated the `override` attribute for `chatButtonCreate`, `chatButtonRemove`, and `chatButtonUpdate` mutations in both `api-gateway` and `api-journeys-modern` schemas. - Updated the GraphQL schema to streamline chat button functionalities and ensure consistency across the codebase. --- apis/api-gateway/schema.graphql | 6 +++--- apis/api-journeys-modern/schema.graphql | 6 +++--- .../src/schema/chatButton/chatButtonCreate.mutation.ts | 1 - .../src/schema/chatButton/chatButtonRemove.mutation.ts | 1 - .../src/schema/chatButton/chatButtonUpdate.mutation.ts | 1 - 5 files changed, 6 insertions(+), 9 deletions(-) diff --git a/apis/api-gateway/schema.graphql b/apis/api-gateway/schema.graphql index 7137278c995..b365724764a 100644 --- a/apis/api-gateway/schema.graphql +++ b/apis/api-gateway/schema.graphql @@ -367,9 +367,9 @@ 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, override: "api-journeys") - chatButtonRemove(id: ID!) : 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") + 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) diff --git a/apis/api-journeys-modern/schema.graphql b/apis/api-journeys-modern/schema.graphql index 7716ebfce50..29b75006d6c 100644 --- a/apis/api-journeys-modern/schema.graphql +++ b/apis/api-journeys-modern/schema.graphql @@ -1543,9 +1543,9 @@ 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") - chatButtonRemove(id: ID!): ChatButton! @override(from: "api-journeys") - chatButtonUpdate(id: ID!, journeyId: ID!, input: ChatButtonUpdateInput!): ChatButton! @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! 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.ts b/apis/api-journeys-modern/src/schema/chatButton/chatButtonRemove.mutation.ts index 5be6b402b95..4e2a6364b4c 100644 --- a/apis/api-journeys-modern/src/schema/chatButton/chatButtonRemove.mutation.ts +++ b/apis/api-journeys-modern/src/schema/chatButton/chatButtonRemove.mutation.ts @@ -11,7 +11,6 @@ builder.mutationField('chatButtonRemove', (t) => .prismaField({ type: ChatButtonRef, nullable: false, - override: { from: 'api-journeys' }, args: { id: t.arg({ type: 'ID', required: true }) }, 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 }), From d404856a2f931333d231c575ef94b6ec6a5938f5 Mon Sep 17 00:00:00 2001 From: Mike Allison Date: Thu, 30 Apr 2026 00:04:08 +0000 Subject: [PATCH 17/26] refactor(journeyViewEvent): update journeyViewEventCreate mutation for modern API - Changed the `journeyViewEventCreate` mutation in `api-gateway` to use the `API_JOURNEYS_MODERN` graph with an override reference. - Added the `journeyViewEventCreate` mutation to `api-journeys-modern` with an override from `api-journeys`. - Updated import structure in the journey module to include the new mutation file. --- apis/api-gateway/schema.graphql | 2 +- apis/api-journeys-modern/schema.graphql | 1 + .../src/schema/event/journey/index.ts | 1 + .../journeyViewEventCreate.mutation.spec.ts | 263 ++++++++++++++++++ .../journeyViewEventCreate.mutation.ts | 89 ++++++ 5 files changed, 355 insertions(+), 1 deletion(-) create mode 100644 apis/api-journeys-modern/src/schema/event/journey/journeyViewEventCreate.mutation.spec.ts create mode 100644 apis/api-journeys-modern/src/schema/event/journey/journeyViewEventCreate.mutation.ts diff --git a/apis/api-gateway/schema.graphql b/apis/api-gateway/schema.graphql index b365724764a..64c9b69f3d5 100644 --- a/apis/api-gateway/schema.graphql +++ b/apis/api-gateway/schema.graphql @@ -123,7 +123,7 @@ type Mutation @join__type(graph: API_ANALYTICS) @join__type(graph: API_JOURNEYS 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) + journeyViewEventCreate(input: JourneyViewEventCreateInput!) : JourneyViewEvent @join__field(graph: API_JOURNEYS_MODERN, override: "api-journeys") stepViewEventCreate(input: StepViewEventCreateInput!) : StepViewEvent! @join__field(graph: API_JOURNEYS) stepNextEventCreate(input: StepNextEventCreateInput!) : StepNextEvent! @join__field(graph: API_JOURNEYS) stepPreviousEventCreate(input: StepPreviousEventCreateInput!) : StepPreviousEvent! @join__field(graph: API_JOURNEYS) diff --git a/apis/api-journeys-modern/schema.graphql b/apis/api-journeys-modern/schema.graphql index 29b75006d6c..5313d2eb4c5 100644 --- a/apis/api-journeys-modern/schema.graphql +++ b/apis/api-journeys-modern/schema.graphql @@ -1552,6 +1552,7 @@ type Mutation { customDomainCreate(input: CustomDomainCreateInput!): CustomDomain! buttonClickEventCreate(input: ButtonClickEventCreateInput!): ButtonClickEvent! chatOpenEventCreate(input: ChatOpenEventCreateInput!): ChatOpenEvent! + journeyViewEventCreate(input: JourneyViewEventCreateInput!): JourneyViewEvent @override(from: "api-journeys") radioQuestionSubmissionEventCreate(input: RadioQuestionSubmissionEventCreateInput!): RadioQuestionSubmissionEvent! multiselectSubmissionEventCreate(input: MultiselectSubmissionEventCreateInput!): MultiselectSubmissionEvent! signUpSubmissionEventCreate(input: SignUpSubmissionEventCreateInput!): SignUpSubmissionEvent! 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 + } + }) +) From b8c3e3c9d08672df37f8f8d129ad3ba35f73bec7 Mon Sep 17 00:00:00 2001 From: Mike Allison Date: Thu, 30 Apr 2026 00:21:00 +0000 Subject: [PATCH 18/26] refactor(stepViewEvent): update stepViewEventCreate mutation for modern API - Changed the `stepViewEventCreate` mutation in `api-gateway` to use the `API_JOURNEYS_MODERN` graph with an override reference. - Added the `stepViewEventCreate` mutation to `api-journeys-modern` with an override from `api-journeys`. - Updated import structure in the step module to include the new mutation file. --- apis/api-gateway/schema.graphql | 2 +- apis/api-journeys-modern/schema.graphql | 1 + .../src/schema/event/step/index.ts | 1 + .../step/stepViewEventCreate.mutation.spec.ts | 271 ++++++++++++++++++ .../step/stepViewEventCreate.mutation.ts | 77 +++++ 5 files changed, 351 insertions(+), 1 deletion(-) create mode 100644 apis/api-journeys-modern/src/schema/event/step/stepViewEventCreate.mutation.spec.ts create mode 100644 apis/api-journeys-modern/src/schema/event/step/stepViewEventCreate.mutation.ts diff --git a/apis/api-gateway/schema.graphql b/apis/api-gateway/schema.graphql index 64c9b69f3d5..9b813f88018 100644 --- a/apis/api-gateway/schema.graphql +++ b/apis/api-gateway/schema.graphql @@ -124,7 +124,7 @@ type Mutation @join__type(graph: API_ANALYTICS) @join__type(graph: API_JOURNEYS period of the previous JourneyViewEvent """ journeyViewEventCreate(input: JourneyViewEventCreateInput!) : JourneyViewEvent @join__field(graph: API_JOURNEYS_MODERN, override: "api-journeys") - stepViewEventCreate(input: StepViewEventCreateInput!) : StepViewEvent! @join__field(graph: 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) diff --git a/apis/api-journeys-modern/schema.graphql b/apis/api-journeys-modern/schema.graphql index 5313d2eb4c5..77a0ce6603d 100644 --- a/apis/api-journeys-modern/schema.graphql +++ b/apis/api-journeys-modern/schema.graphql @@ -1553,6 +1553,7 @@ type Mutation { 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! 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..4a96cac7fe5 --- /dev/null +++ b/apis/api-journeys-modern/src/schema/event/step/stepViewEventCreate.mutation.ts @@ -0,0 +1,77 @@ +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 + } + }) +) From 7ad9bccf1dfa1fd2afe9dce6b53014ce3e500160 Mon Sep 17 00:00:00 2001 From: Mike Allison Date: Thu, 30 Apr 2026 00:33:59 +0000 Subject: [PATCH 19/26] refactor(videoStartEvent): update videoStartEventCreate mutation for modern API - Changed the `videoStartEventCreate` mutation in `api-gateway` to use the `API_JOURNEYS_MODERN` graph with an override reference. - Added the `videoStartEventCreate` mutation to `api-journeys-modern` with an override from `api-journeys`. - Updated import structure in the video module to include the new mutation file. --- apis/api-gateway/schema.graphql | 2 +- apis/api-journeys-modern/schema.graphql | 1 + .../src/schema/event/video/index.ts | 1 + .../videoStartEventCreate.mutation.spec.ts | 260 ++++++++++++++++++ .../video/videoStartEventCreate.mutation.ts | 62 +++++ 5 files changed, 325 insertions(+), 1 deletion(-) create mode 100644 apis/api-journeys-modern/src/schema/event/video/videoStartEventCreate.mutation.spec.ts create mode 100644 apis/api-journeys-modern/src/schema/event/video/videoStartEventCreate.mutation.ts diff --git a/apis/api-gateway/schema.graphql b/apis/api-gateway/schema.graphql index 9b813f88018..a4fd5f7b127 100644 --- a/apis/api-gateway/schema.graphql +++ b/apis/api-gateway/schema.graphql @@ -127,7 +127,7 @@ type Mutation @join__type(graph: API_ANALYTICS) @join__type(graph: 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) + videoStartEventCreate(input: VideoStartEventCreateInput!) : VideoStartEvent! @join__field(graph: API_JOURNEYS_MODERN, override: "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) diff --git a/apis/api-journeys-modern/schema.graphql b/apis/api-journeys-modern/schema.graphql index 77a0ce6603d..933fbf6a269 100644 --- a/apis/api-journeys-modern/schema.graphql +++ b/apis/api-journeys-modern/schema.graphql @@ -1558,6 +1558,7 @@ type Mutation { multiselectSubmissionEventCreate(input: MultiselectSubmissionEventCreateInput!): MultiselectSubmissionEvent! signUpSubmissionEventCreate(input: SignUpSubmissionEventCreateInput!): SignUpSubmissionEvent! textResponseSubmissionEventCreate(input: TextResponseSubmissionEventCreateInput!): TextResponseSubmissionEvent! + videoStartEventCreate(input: VideoStartEventCreateInput!): VideoStartEvent! @override(from: "api-journeys") journeySimpleUpdate(id: ID!, journey: Json!): Json googleSheetsSyncCreate(input: CreateGoogleSheetsSyncInput!): GoogleSheetsSync! googleSheetsSyncDelete(id: ID!): GoogleSheetsSync! 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..10f4321640b 100644 --- a/apis/api-journeys-modern/src/schema/event/video/index.ts +++ b/apis/api-journeys-modern/src/schema/event/video/index.ts @@ -1,4 +1,5 @@ import './videoStartEvent' +import './videoStartEventCreate.mutation' import './videoPlayEvent' import './videoPauseEvent' import './videoCompleteEvent' 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 + } + }) + } + }) +) From 06ecb0e2475aa62452879f0241f9b817723283c1 Mon Sep 17 00:00:00 2001 From: Mike Allison Date: Thu, 30 Apr 2026 00:39:30 +0000 Subject: [PATCH 20/26] refactor(videoPlayEvent): update videoPlayEventCreate mutation for modern API - Changed the `videoPlayEventCreate` mutation in `api-gateway` to use the `API_JOURNEYS_MODERN` graph with an override reference. - Added the `videoPlayEventCreate` mutation to `api-journeys-modern` with an override from `api-journeys`. - Updated import structure in the video module to include the new mutation file. --- apis/api-gateway/schema.graphql | 2 +- apis/api-journeys-modern/schema.graphql | 1 + .../src/schema/event/video/index.ts | 1 + .../videoPlayEventCreate.mutation.spec.ts | 260 ++++++++++++++++++ .../video/videoPlayEventCreate.mutation.ts | 62 +++++ 5 files changed, 325 insertions(+), 1 deletion(-) create mode 100644 apis/api-journeys-modern/src/schema/event/video/videoPlayEventCreate.mutation.spec.ts create mode 100644 apis/api-journeys-modern/src/schema/event/video/videoPlayEventCreate.mutation.ts diff --git a/apis/api-gateway/schema.graphql b/apis/api-gateway/schema.graphql index a4fd5f7b127..e5b0a6ce7e3 100644 --- a/apis/api-gateway/schema.graphql +++ b/apis/api-gateway/schema.graphql @@ -128,7 +128,7 @@ type Mutation @join__type(graph: API_ANALYTICS) @join__type(graph: 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_MODERN, override: "api-journeys") - videoPlayEventCreate(input: VideoPlayEventCreateInput!) : VideoPlayEvent! @join__field(graph: API_JOURNEYS) + videoPlayEventCreate(input: VideoPlayEventCreateInput!) : VideoPlayEvent! @join__field(graph: API_JOURNEYS_MODERN, override: "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) diff --git a/apis/api-journeys-modern/schema.graphql b/apis/api-journeys-modern/schema.graphql index 933fbf6a269..247d8652393 100644 --- a/apis/api-journeys-modern/schema.graphql +++ b/apis/api-journeys-modern/schema.graphql @@ -1559,6 +1559,7 @@ type Mutation { signUpSubmissionEventCreate(input: SignUpSubmissionEventCreateInput!): SignUpSubmissionEvent! textResponseSubmissionEventCreate(input: TextResponseSubmissionEventCreateInput!): TextResponseSubmissionEvent! videoStartEventCreate(input: VideoStartEventCreateInput!): VideoStartEvent! @override(from: "api-journeys") + videoPlayEventCreate(input: VideoPlayEventCreateInput!): VideoPlayEvent! @override(from: "api-journeys") journeySimpleUpdate(id: ID!, journey: Json!): Json googleSheetsSyncCreate(input: CreateGoogleSheetsSyncInput!): GoogleSheetsSync! googleSheetsSyncDelete(id: ID!): GoogleSheetsSync! 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 10f4321640b..9924098954a 100644 --- a/apis/api-journeys-modern/src/schema/event/video/index.ts +++ b/apis/api-journeys-modern/src/schema/event/video/index.ts @@ -1,6 +1,7 @@ import './videoStartEvent' import './videoStartEventCreate.mutation' import './videoPlayEvent' +import './videoPlayEventCreate.mutation' import './videoPauseEvent' import './videoCompleteEvent' import './videoExpandEvent' 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 + } + }) + } + }) +) From 73e67367d563649b656d02491d2fa21d6b0d7181 Mon Sep 17 00:00:00 2001 From: Mike Allison Date: Thu, 30 Apr 2026 01:03:39 +0000 Subject: [PATCH 21/26] refactor(videoPauseEvent): update videoPauseEventCreate mutation for modern API - Changed the `videoPauseEventCreate` mutation in `api-gateway` to use the `API_JOURNEYS_MODERN` graph with an override reference. - Added the `videoPauseEventCreate` mutation to `api-journeys-modern` with an override from `api-journeys`. - Updated import structure in the video module to include the new mutation file. --- apis/api-gateway/schema.graphql | 2 +- apis/api-journeys-modern/schema.graphql | 1 + .../src/schema/event/video/index.ts | 1 + .../videoPauseEventCreate.mutation.spec.ts | 165 ++++++++++++++++++ .../video/videoPauseEventCreate.mutation.ts | 46 +++++ 5 files changed, 214 insertions(+), 1 deletion(-) create mode 100644 apis/api-journeys-modern/src/schema/event/video/videoPauseEventCreate.mutation.spec.ts create mode 100644 apis/api-journeys-modern/src/schema/event/video/videoPauseEventCreate.mutation.ts diff --git a/apis/api-gateway/schema.graphql b/apis/api-gateway/schema.graphql index e5b0a6ce7e3..176cf8b95d1 100644 --- a/apis/api-gateway/schema.graphql +++ b/apis/api-gateway/schema.graphql @@ -129,7 +129,7 @@ type Mutation @join__type(graph: API_ANALYTICS) @join__type(graph: API_JOURNEYS stepPreviousEventCreate(input: StepPreviousEventCreateInput!) : StepPreviousEvent! @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) + videoPauseEventCreate(input: VideoPauseEventCreateInput!) : VideoPauseEvent! @join__field(graph: API_JOURNEYS_MODERN, override: "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) diff --git a/apis/api-journeys-modern/schema.graphql b/apis/api-journeys-modern/schema.graphql index 247d8652393..2fed22ed8c5 100644 --- a/apis/api-journeys-modern/schema.graphql +++ b/apis/api-journeys-modern/schema.graphql @@ -1560,6 +1560,7 @@ type Mutation { 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") journeySimpleUpdate(id: ID!, journey: Json!): Json googleSheetsSyncCreate(input: CreateGoogleSheetsSyncInput!): GoogleSheetsSync! googleSheetsSyncDelete(id: ID!): GoogleSheetsSync! 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 9924098954a..69bb8d26bc1 100644 --- a/apis/api-journeys-modern/src/schema/event/video/index.ts +++ b/apis/api-journeys-modern/src/schema/event/video/index.ts @@ -3,6 +3,7 @@ import './videoStartEventCreate.mutation' import './videoPlayEvent' import './videoPlayEventCreate.mutation' import './videoPauseEvent' +import './videoPauseEventCreate.mutation' import './videoCompleteEvent' import './videoExpandEvent' import './videoCollapseEvent' 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 + } + }) + } + }) +) From a01cd0119401b09a726143d16ecf7d8e705bac84 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Thu, 30 Apr 2026 01:42:12 +0000 Subject: [PATCH 22/26] fix: lint issues --- .../event/step/stepViewEventCreate.mutation.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) 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 index 4a96cac7fe5..af7d5eda938 100644 --- a/apis/api-journeys-modern/src/schema/event/step/stepViewEventCreate.mutation.ts +++ b/apis/api-journeys-modern/src/schema/event/step/stepViewEventCreate.mutation.ts @@ -22,8 +22,11 @@ builder.mutationField('stepViewEventCreate', (t) => throw new Error('User not authenticated') } - const { visitor, journeyVisitor, journeyId } = - await validateBlockEvent(userId, input.blockId, input.blockId) + const { visitor, journeyVisitor, journeyId } = await validateBlockEvent( + userId, + input.blockId, + input.blockId + ) const [stepViewEvent] = await Promise.all([ prisma.event.create({ @@ -42,9 +45,8 @@ builder.mutationField('stepViewEventCreate', (t) => duration: Math.min( 1200, Math.floor( - Math.abs( - Date.now() - new Date(visitor.createdAt).getTime() - ) / 1000 + Math.abs(Date.now() - new Date(visitor.createdAt).getTime()) / + 1000 ) ), lastStepViewedAt: new Date() From 168b9a34975f09cb0ece41f2a99d6066a77b2b49 Mon Sep 17 00:00:00 2001 From: Mike Allison Date: Fri, 1 May 2026 22:30:15 +0000 Subject: [PATCH 23/26] refactor(videoCompleteEvent): update videoCompleteEventCreate mutation for modern API - Changed the `videoCompleteEventCreate` mutation in `api-gateway` to use the `API_JOURNEYS_MODERN` graph with an override reference. - Added the `videoCompleteEventCreate` mutation to `api-journeys-modern` with an override from `api-journeys`. - Updated import structure in the video module to include the new mutation file. --- apis/api-gateway/schema.graphql | 2 +- apis/api-journeys-modern/schema.graphql | 1 + .../src/schema/event/video/index.ts | 1 + .../videoCompleteEventCreate.mutation.spec.ts | 172 ++++++++++++++++++ .../videoCompleteEventCreate.mutation.ts | 48 +++++ 5 files changed, 223 insertions(+), 1 deletion(-) create mode 100644 apis/api-journeys-modern/src/schema/event/video/videoCompleteEventCreate.mutation.spec.ts create mode 100644 apis/api-journeys-modern/src/schema/event/video/videoCompleteEventCreate.mutation.ts diff --git a/apis/api-gateway/schema.graphql b/apis/api-gateway/schema.graphql index 176cf8b95d1..6be3f25c1f1 100644 --- a/apis/api-gateway/schema.graphql +++ b/apis/api-gateway/schema.graphql @@ -130,7 +130,7 @@ type Mutation @join__type(graph: API_ANALYTICS) @join__type(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) + videoCompleteEventCreate(input: VideoCompleteEventCreateInput!) : VideoCompleteEvent! @join__field(graph: API_JOURNEYS_MODERN, override: "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) diff --git a/apis/api-journeys-modern/schema.graphql b/apis/api-journeys-modern/schema.graphql index 2fed22ed8c5..63fc440e9ef 100644 --- a/apis/api-journeys-modern/schema.graphql +++ b/apis/api-journeys-modern/schema.graphql @@ -1561,6 +1561,7 @@ type Mutation { 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") journeySimpleUpdate(id: ID!, journey: Json!): Json googleSheetsSyncCreate(input: CreateGoogleSheetsSyncInput!): GoogleSheetsSync! googleSheetsSyncDelete(id: ID!): GoogleSheetsSync! 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 69bb8d26bc1..d72af7e32bc 100644 --- a/apis/api-journeys-modern/src/schema/event/video/index.ts +++ b/apis/api-journeys-modern/src/schema/event/video/index.ts @@ -5,6 +5,7 @@ import './videoPlayEventCreate.mutation' import './videoPauseEvent' import './videoPauseEventCreate.mutation' import './videoCompleteEvent' +import './videoCompleteEventCreate.mutation' import './videoExpandEvent' import './videoCollapseEvent' import './videoProgressEvent' 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 + } + }) + } + }) +) From 86ef52d17448a1cd595749f81c8a181eca664065 Mon Sep 17 00:00:00 2001 From: Mike Allison Date: Fri, 1 May 2026 22:41:39 +0000 Subject: [PATCH 24/26] refactor(videoExpandEvent): update videoExpandEventCreate mutation for modern API - Changed the `videoExpandEventCreate` mutation in `api-gateway` to use the `API_JOURNEYS_MODERN` graph with an override reference. - Added the `videoExpandEventCreate` mutation to `api-journeys-modern` with an override from `api-journeys`. - Updated import structure in the video module to include the new mutation file. --- apis/api-gateway/schema.graphql | 2 +- apis/api-journeys-modern/schema.graphql | 1 + .../src/schema/event/video/index.ts | 1 + .../videoExpandEventCreate.mutation.spec.ts | 165 ++++++++++++++++++ .../video/videoExpandEventCreate.mutation.ts | 46 +++++ 5 files changed, 214 insertions(+), 1 deletion(-) create mode 100644 apis/api-journeys-modern/src/schema/event/video/videoExpandEventCreate.mutation.spec.ts create mode 100644 apis/api-journeys-modern/src/schema/event/video/videoExpandEventCreate.mutation.ts diff --git a/apis/api-gateway/schema.graphql b/apis/api-gateway/schema.graphql index 6be3f25c1f1..0f5da1eed1c 100644 --- a/apis/api-gateway/schema.graphql +++ b/apis/api-gateway/schema.graphql @@ -131,7 +131,7 @@ type Mutation @join__type(graph: API_ANALYTICS) @join__type(graph: 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) + videoExpandEventCreate(input: VideoExpandEventCreateInput!) : VideoExpandEvent! @join__field(graph: API_JOURNEYS_MODERN, override: "api-journeys") videoCollapseEventCreate(input: VideoCollapseEventCreateInput!) : VideoCollapseEvent! @join__field(graph: API_JOURNEYS) videoProgressEventCreate(input: VideoProgressEventCreateInput!) : VideoProgressEvent! @join__field(graph: API_JOURNEYS) hostCreate(teamId: ID!, input: HostCreateInput!) : Host! @join__field(graph: API_JOURNEYS) diff --git a/apis/api-journeys-modern/schema.graphql b/apis/api-journeys-modern/schema.graphql index 63fc440e9ef..35428ff2848 100644 --- a/apis/api-journeys-modern/schema.graphql +++ b/apis/api-journeys-modern/schema.graphql @@ -1562,6 +1562,7 @@ type Mutation { 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") journeySimpleUpdate(id: ID!, journey: Json!): Json googleSheetsSyncCreate(input: CreateGoogleSheetsSyncInput!): GoogleSheetsSync! googleSheetsSyncDelete(id: ID!): GoogleSheetsSync! 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 d72af7e32bc..4a7ae931b11 100644 --- a/apis/api-journeys-modern/src/schema/event/video/index.ts +++ b/apis/api-journeys-modern/src/schema/event/video/index.ts @@ -7,6 +7,7 @@ import './videoPauseEventCreate.mutation' import './videoCompleteEvent' import './videoCompleteEventCreate.mutation' import './videoExpandEvent' +import './videoExpandEventCreate.mutation' import './videoCollapseEvent' import './videoProgressEvent' import './inputs' 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 + } + }) + } + }) +) From b1e5cf6db3402bb49278c240d9cb2cd2ba60e766 Mon Sep 17 00:00:00 2001 From: Mike Allison Date: Fri, 1 May 2026 22:50:16 +0000 Subject: [PATCH 25/26] refactor(videoCollapseEvent): update videoCollapseEventCreate mutation for modern API - Changed the `videoCollapseEventCreate` mutation in `api-gateway` to use the `API_JOURNEYS_MODERN` graph with an override reference. - Added the `videoCollapseEventCreate` mutation to `api-journeys-modern` with an override from `api-journeys`. - Updated import structure in the video module to include the new mutation file. --- apis/api-gateway/schema.graphql | 2 +- apis/api-journeys-modern/schema.graphql | 1 + .../src/schema/event/video/index.ts | 1 + .../videoCollapseEventCreate.mutation.spec.ts | 165 ++++++++++++++++++ .../videoCollapseEventCreate.mutation.ts | 46 +++++ 5 files changed, 214 insertions(+), 1 deletion(-) create mode 100644 apis/api-journeys-modern/src/schema/event/video/videoCollapseEventCreate.mutation.spec.ts create mode 100644 apis/api-journeys-modern/src/schema/event/video/videoCollapseEventCreate.mutation.ts diff --git a/apis/api-gateway/schema.graphql b/apis/api-gateway/schema.graphql index 0f5da1eed1c..02d99a22398 100644 --- a/apis/api-gateway/schema.graphql +++ b/apis/api-gateway/schema.graphql @@ -132,7 +132,7 @@ type Mutation @join__type(graph: API_ANALYTICS) @join__type(graph: 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) + videoCollapseEventCreate(input: VideoCollapseEventCreateInput!) : VideoCollapseEvent! @join__field(graph: API_JOURNEYS_MODERN, override: "api-journeys") videoProgressEventCreate(input: VideoProgressEventCreateInput!) : VideoProgressEvent! @join__field(graph: 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) diff --git a/apis/api-journeys-modern/schema.graphql b/apis/api-journeys-modern/schema.graphql index 35428ff2848..714232f2c8d 100644 --- a/apis/api-journeys-modern/schema.graphql +++ b/apis/api-journeys-modern/schema.graphql @@ -1563,6 +1563,7 @@ type Mutation { 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") journeySimpleUpdate(id: ID!, journey: Json!): Json googleSheetsSyncCreate(input: CreateGoogleSheetsSyncInput!): GoogleSheetsSync! googleSheetsSyncDelete(id: ID!): GoogleSheetsSync! 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 4a7ae931b11..41e49fc6426 100644 --- a/apis/api-journeys-modern/src/schema/event/video/index.ts +++ b/apis/api-journeys-modern/src/schema/event/video/index.ts @@ -9,5 +9,6 @@ import './videoCompleteEventCreate.mutation' import './videoExpandEvent' import './videoExpandEventCreate.mutation' import './videoCollapseEvent' +import './videoCollapseEventCreate.mutation' import './videoProgressEvent' 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 + } + }) + } + }) +) From 9c85decc9c1619fcbad3d077d61f2a06db06bfc8 Mon Sep 17 00:00:00 2001 From: Mike Allison Date: Fri, 1 May 2026 23:14:12 +0000 Subject: [PATCH 26/26] refactor(videoProgressEvent): update videoProgressEventCreate mutation for modern API - Changed the `videoProgressEventCreate` mutation in `api-gateway` to use the `API_JOURNEYS_MODERN` graph with an override reference. - Added the `videoProgressEventCreate` mutation to `api-journeys-modern` with an override from `api-journeys`. - Updated import structure in the video module to include the new mutation file. --- apis/api-gateway/schema.graphql | 2 +- apis/api-journeys-modern/schema.graphql | 1 + .../src/schema/event/video/index.ts | 1 + .../videoProgressEventCreate.mutation.spec.ts | 174 ++++++++++++++++++ .../videoProgressEventCreate.mutation.ts | 47 +++++ 5 files changed, 224 insertions(+), 1 deletion(-) create mode 100644 apis/api-journeys-modern/src/schema/event/video/videoProgressEventCreate.mutation.spec.ts create mode 100644 apis/api-journeys-modern/src/schema/event/video/videoProgressEventCreate.mutation.ts diff --git a/apis/api-gateway/schema.graphql b/apis/api-gateway/schema.graphql index 02d99a22398..ae647e27c10 100644 --- a/apis/api-gateway/schema.graphql +++ b/apis/api-gateway/schema.graphql @@ -133,7 +133,7 @@ type Mutation @join__type(graph: API_ANALYTICS) @join__type(graph: 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) + 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) diff --git a/apis/api-journeys-modern/schema.graphql b/apis/api-journeys-modern/schema.graphql index 714232f2c8d..565beccc049 100644 --- a/apis/api-journeys-modern/schema.graphql +++ b/apis/api-journeys-modern/schema.graphql @@ -1564,6 +1564,7 @@ type Mutation { 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! 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 41e49fc6426..1ff36232712 100644 --- a/apis/api-journeys-modern/src/schema/event/video/index.ts +++ b/apis/api-journeys-modern/src/schema/event/video/index.ts @@ -11,4 +11,5 @@ 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/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 + } + }) + } + }) +)