diff --git a/codegen.mts b/codegen.mts index 6adbbfaea7..7458fc946a 100644 --- a/codegen.mts +++ b/codegen.mts @@ -41,6 +41,7 @@ const config: CodegenConfig = { OrganizationAccessScope: '../modules/auth/providers/scopes#OrganizationAccessScope', SupportTicketPriority: '../shared/entities#SupportTicketPriority', SupportTicketStatus: '../shared/entities#SupportTicketStatus', + SupportCategoryType: '../shared/entities#SupportCategoryType', }, resolversNonOptionalTypename: { interfaceImplementingType: true, diff --git a/packages/services/api/src/modules/audit-logs/providers/audit-logs-types.ts b/packages/services/api/src/modules/audit-logs/providers/audit-logs-types.ts index e9dab091d6..35f056a57b 100644 --- a/packages/services/api/src/modules/audit-logs/providers/audit-logs-types.ts +++ b/packages/services/api/src/modules/audit-logs/providers/audit-logs-types.ts @@ -196,6 +196,9 @@ export const AuditLogModel = z.union([ metadata: z.object({ ticketId: z.string(), ticketSubject: z.string(), + ticketCategory: z.string().optional(), + ticketProject: z.string().optional(), + ticketTarget: z.string().optional(), ticketDescription: z.string(), ticketPriority: z.string(), }), diff --git a/packages/services/api/src/modules/support/module.graphql.mappers.ts b/packages/services/api/src/modules/support/module.graphql.mappers.ts index 4d2c627e6d..fd18017217 100644 --- a/packages/services/api/src/modules/support/module.graphql.mappers.ts +++ b/packages/services/api/src/modules/support/module.graphql.mappers.ts @@ -1,4 +1,9 @@ -import { SupportTicketPriority, SupportTicketStatus } from '../../shared/entities'; +import { + SupportCategoryType, + SupportTicketPriority, + SupportTicketStatus, +} from '../../shared/entities'; export type SupportTicketPriorityMapper = SupportTicketPriority; export type SupportTicketStatusMapper = SupportTicketStatus; +export type SupportCategoryTypeMapper = SupportCategoryType; diff --git a/packages/services/api/src/modules/support/module.graphql.ts b/packages/services/api/src/modules/support/module.graphql.ts index de34b54ae9..9cbe1ca79a 100644 --- a/packages/services/api/src/modules/support/module.graphql.ts +++ b/packages/services/api/src/modules/support/module.graphql.ts @@ -30,6 +30,9 @@ export default gql` input SupportTicketCreateInput { organizationSlug: String! + projectId: String + targetId: String + category: SupportCategoryType subject: String! description: String! priority: SupportTicketPriority! @@ -95,6 +98,13 @@ export default gql` fromSupport: Boolean! } + enum SupportCategoryType { + TECHNICAL_ISSUE + BILLING + COMPLIANCE + OTHER + } + enum SupportTicketPriority { NORMAL HIGH diff --git a/packages/services/api/src/modules/support/providers/support-manager.ts b/packages/services/api/src/modules/support/providers/support-manager.ts index 578fc859c0..86b72ca806 100644 --- a/packages/services/api/src/modules/support/providers/support-manager.ts +++ b/packages/services/api/src/modules/support/providers/support-manager.ts @@ -1,7 +1,12 @@ import { createHash } from 'node:crypto'; import { Inject, Injectable, Scope } from 'graphql-modules'; import { z } from 'zod'; -import { Organization, SupportTicketPriority, SupportTicketStatus } from '../../../shared/entities'; +import { + Organization, + SupportCategoryType, + SupportTicketPriority, + SupportTicketStatus, +} from '../../../shared/entities'; import { atomic } from '../../../shared/helpers'; import { AuditLogRecorder } from '../../audit-logs/providers/audit-log-recorder'; import { Session } from '../../auth/lib/authz'; @@ -23,6 +28,7 @@ export const SupportTicketStatusAPIModel = z.enum([ export const SupportTicketPriorityModel = z.nativeEnum(SupportTicketPriority); export const SupportTicketStatusModel = z.nativeEnum(SupportTicketStatus); +export const SupportTicketCategoryModel = z.nativeEnum(SupportCategoryType); const SupportTicketModel = z.object({ id: z.number(), @@ -45,6 +51,9 @@ const SupportTicketModel = z.object({ return SupportTicketStatusModel.parse(value); }), + category: SupportTicketCategoryModel, + project: z.string().optional(), + target: z.string().optional(), created_at: z.string(), updated_at: z.string(), subject: z.string(), @@ -80,6 +89,9 @@ const SupportTicketCommentListModel = z.object({ const SupportTicketCreateRequestModel = z.object({ organizationId: z.string(), + category: SupportTicketCategoryModel, + project: z.string().optional(), + target: z.string().optional(), subject: z.string().min(3), description: z.string().min(3), priority: SupportTicketPriorityModel, @@ -508,6 +520,9 @@ export class SupportManager { organizationId: string; subject: string; description: string; + category?: z.infer; + project?: string; + target?: string; priority: z.infer; }) { this.logger.info( @@ -549,6 +564,9 @@ export class SupportManager { subject: input.subject, description: input.description, priority: input.priority, + category: input.category, + project: input.project, + target: input.target, // version is here to cache bust the idempotency key. version: 'v2', }), @@ -560,6 +578,12 @@ export class SupportManager { }); const customerType = this.resolveCustomerType(organization); + const formattedBody = ` "Category: " + ${request.data.category ? request.data.category : 'Not Selected'}\n\n + "Project: " + ${request.data.project ? request.data.project : 'Not Selected'}\n\n + "Target: " + ${request.data.target ? request.data.target : 'Not Selected'}\n\n + "Description: " + ${request.data.description} + `; + const response = await this.httpClient .post(`https://${this.config.subdomain}.zendesk.com/api/v2/tickets`, { username: this.config.username, @@ -570,7 +594,7 @@ export class SupportManager { submitter_id: parseInt(internalUserId, 10), requester_id: parseInt(internalUserId, 10), comment: { - body: request.data.description, + body: formattedBody, }, priority: request.data.priority, subject: request.data.subject, @@ -598,6 +622,9 @@ export class SupportManager { metadata: { ticketDescription: input.description, ticketPriority: input.priority, + ...(input.category ? { ticketCategory: input.category } : {}), + ...(input.project ? { ticketProject: input.project } : {}), + ...(input.target ? { ticketTarget: input.target } : {}), ticketId: String(response.ticket.id), ticketSubject: input.subject, }, diff --git a/packages/services/api/src/modules/support/resolvers/Mutation/supportTicketCreate.ts b/packages/services/api/src/modules/support/resolvers/Mutation/supportTicketCreate.ts index 79433769ff..9b18b8a064 100644 --- a/packages/services/api/src/modules/support/resolvers/Mutation/supportTicketCreate.ts +++ b/packages/services/api/src/modules/support/resolvers/Mutation/supportTicketCreate.ts @@ -11,6 +11,7 @@ export const supportTicketCreate: NonNullable | null; + onValueChange?: ((value: string) => void) | undefined; + optional?: boolean; + showOrganization?: boolean; }) { const router = useRouter(); - + const optional = typeof props.optional !== 'undefined' ? props.optional : false; + const showOrganization = + typeof props.showOrganization !== 'undefined' ? props.showOrganization : true; + const defaultFunc = (id: string) => { + void router.navigate({ + to: '/$organizationSlug/$projectSlug', + params: { + organizationSlug: props.currentOrganizationSlug, + projectSlug: id, + }, + }); + }; + const onValueChangeFunc = + typeof props.onValueChange !== 'undefined' ? props.onValueChange : defaultFunc; const organizations = useFragment( ProjectSelector_OrganizationConnectionFragment, props.organizations, @@ -42,47 +59,51 @@ export function ProjectSelector(props: { return ( <> - {currentOrganization ? ( - - {currentOrganization.slug} - + {showOrganization ? ( + currentOrganization ? ( + + {currentOrganization.slug} + + ) : ( +
+ ) ) : ( -
+ '' )} - {projectEdges?.length && currentProject ? ( + {(projectEdges?.length && currentProject) || optional ? ( <> -
/
-
- {currentProject.slug} + {optional ? ( + + ) : ( + (currentProject?.slug ?? '') + )}
- {projectEdges.map(edge => ( - - {edge.node.slug} + {optional ? ( + + Unassigned - ))} + ) : null} + {projectEdges?.map(edge => { + return ( + + {edge.node.slug} + + ); + })} diff --git a/packages/web/app/src/components/layouts/target-selector.tsx b/packages/web/app/src/components/layouts/target-selector.tsx index f2614ac2b1..5f29a92a8a 100644 --- a/packages/web/app/src/components/layouts/target-selector.tsx +++ b/packages/web/app/src/components/layouts/target-selector.tsx @@ -1,6 +1,7 @@ -import { Select, SelectContent, SelectItem, SelectTrigger } from '@/components/ui/select'; import { FragmentType, graphql, useFragment } from '@/gql'; -import { Link, useRouter } from '@tanstack/react-router'; +import { useRouter } from '@tanstack/react-router'; +import { Link } from '../ui/link'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'; const TargetSelector_OrganizationConnectionFragment = graphql(` fragment TargetSelector_OrganizationConnectionFragment on OrganizationConnection { @@ -31,10 +32,31 @@ export function TargetSelector(props: { currentOrganizationSlug: string; currentProjectSlug: string; currentTargetSlug: string; + optional?: boolean; + showOrganization?: boolean; + showProject?: boolean; + onValueChange?: ((value: string) => void) | undefined; organizations: FragmentType | null; }) { const router = useRouter(); + const showOrganization = + typeof props.showOrganization !== 'undefined' ? props.showOrganization : true; + const showProject = typeof props.showProject !== 'undefined' ? props.showProject : true; + const isOptional = typeof props.optional !== 'undefined' ? props.optional : false; + const defaultOnValueChange = (id: string) => { + void router.navigate({ + to: '/$organizationSlug/$projectSlug/$targetSlug', + params: { + organizationSlug: props.currentOrganizationSlug, + projectSlug: props.currentProjectSlug, + targetSlug: id, + }, + }); + }; + const onValueChangeFunc = + typeof props.onValueChange !== 'undefined' ? props.onValueChange : defaultOnValueChange; + const organizations = useFragment( TargetSelector_OrganizationConnectionFragment, props.organizations, @@ -52,65 +74,73 @@ export function TargetSelector(props: { return ( <> - {currentOrganization ? ( - - {currentOrganization.slug} - - ) : ( -
- )} -
/
- {currentOrganization && currentProject ? ( - - {currentProject.slug} - - ) : ( -
- )} -
/
- {targetEdges?.length && currentOrganization && currentProject && currentTarget ? ( - <> -
- {currentTarget.slug} + {isOptional ? ( + + ) : ( + (currentTarget?.slug ?? '') + )}
- {targetEdges.map(edge => ( - - {edge.node.slug} + {isOptional ? ( + + Unassigned - ))} + ) : ( + <> + )} + {targetEdges ? ( + targetEdges.map(edge => ( + + {edge.node.slug} + + )) + ) : ( + <> + )} diff --git a/packages/web/app/src/pages/organization-support.tsx b/packages/web/app/src/pages/organization-support.tsx index 61e717aa58..b1a12617d2 100644 --- a/packages/web/app/src/pages/organization-support.tsx +++ b/packages/web/app/src/pages/organization-support.tsx @@ -1,9 +1,11 @@ -import { useCallback } from 'react'; +import { SetStateAction, useCallback, useState } from 'react'; import { PencilIcon } from 'lucide-react'; import { useForm } from 'react-hook-form'; import { useMutation, useQuery } from 'urql'; import { z } from 'zod'; import { OrganizationLayout, Page } from '@/components/layouts/organization'; +import { ProjectSelector } from '@/components/layouts/project-selector'; +import { TargetSelector } from '@/components/layouts/target-selector'; import { Priority, priorityDescription, Status } from '@/components/organization/support'; import { Button } from '@/components/ui/button'; import { @@ -20,6 +22,7 @@ import { Meta } from '@/components/ui/meta'; import { Subtitle, Title } from '@/components/ui/page'; import { QueryError } from '@/components/ui/query-error'; import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'; +import { Select, SelectContent, SelectItem, SelectTrigger } from '@/components/ui/select'; import { Sheet, SheetContent, @@ -40,16 +43,30 @@ import { Textarea } from '@/components/ui/textarea'; import { TimeAgo } from '@/components/ui/time-ago'; import { TooltipProvider } from '@/components/ui/tooltip'; import { FragmentType, graphql, useFragment } from '@/gql'; -import { SupportTicketPriority, SupportTicketStatus } from '@/gql/graphql'; +import { SupportCategoryType, SupportTicketPriority, SupportTicketStatus } from '@/gql/graphql'; import { useNotifications, useToggle } from '@/lib/hooks'; import { cn } from '@/lib/utils'; import { zodResolver } from '@hookform/resolvers/zod'; import { Link } from '@tanstack/react-router'; +const NewTicketQuery = graphql(` + query NewTicketQuery { + organizations { + ...ProjectSelector_OrganizationConnectionFragment + ...TargetSelector_OrganizationConnectionFragment + } + } +`); + const newTicketFormSchema = z.object({ subject: z.string().min(2, { message: 'Subject must be at least 2 characters.', }), + category: z.nativeEnum(SupportCategoryType, { + required_error: 'A category is required.', + }), + project: z.string().optional(), + target: z.string().optional(), priority: z.nativeEnum(SupportTicketPriority, { required_error: 'A priority is required.', }), @@ -79,11 +96,19 @@ function NewTicketForm(props: { onClose: () => void; onSubmit: () => void; }) { + const [project, setProject] = useState(''); + + const [query] = useQuery({ + query: NewTicketQuery, + requestPolicy: 'cache-first', + }); + const notify = useNotifications(); const form = useForm({ resolver: zodResolver(newTicketFormSchema), defaultValues: { subject: '', + category: SupportCategoryType.Other, priority: SupportTicketPriority.Normal, description: '', }, @@ -104,6 +129,9 @@ function NewTicketForm(props: { const result = await mutate({ input: { organizationSlug: props.organizationSlug, + category: data.category, + projectId: data.project !== 'empty' ? data.project : undefined, + targetId: data.target !== 'empty' ? data.target : undefined, subject: data.subject, priority: data.priority, description: data.description, @@ -139,7 +167,7 @@ function NewTicketForm(props: { } }} > - +
)} /> + ( + + Category + + + + + + )} + /> + ( + + Project (Optional) + + ) => { + field.onChange(value); + setProject(value); + }} + organizations={query?.data?.organizations || null} + optional + showOrganization={false} + /> + + + + )} + /> + ( + + Target (Optional) + + + + + )} + /> + - {(tickets ?? []).map(ticket => ( + {tickets?.map(ticket => (