diff --git a/.source b/.source index 10dafb964b7..0b64e95de0b 160000 --- a/.source +++ b/.source @@ -1 +1 @@ -Subproject commit 10dafb964b790e2bb359b19f66bc8906709018a5 +Subproject commit 0b64e95de0bb8b5f1c4827201d86ef2489eff3fa diff --git a/apps/dashboard/src/api/ai.ts b/apps/dashboard/src/api/ai.ts index 1aa4ef9c972..323f4c35541 100644 --- a/apps/dashboard/src/api/ai.ts +++ b/apps/dashboard/src/api/ai.ts @@ -3,6 +3,7 @@ import { AiMessageRoleEnum, AiResourceTypeEnum, IEnvironment, + StepTypeEnum, WorkflowResponseDto, } from '@novu/shared'; import { UIMessage } from 'ai'; @@ -146,6 +147,29 @@ export async function revertMessage({ }); } +export type WorkflowSuggestionResponse = { + id: string; + title: string; + description: string; + examplePrompt: string; + steps: StepTypeEnum[]; +}; + +export async function fetchWorkflowSuggestions({ + environment, + refresh, +}: { + environment: IEnvironment; + refresh?: boolean; +}): Promise { + const endpoint = refresh ? '/ai/workflow-suggestions?refresh=true' : '/ai/workflow-suggestions'; + const { data: responseData } = await getV2<{ data: WorkflowSuggestionResponse[] }>(endpoint, { + environment, + }); + + return responseData; +} + export async function cancelStream({ environment, chatId, diff --git a/apps/dashboard/src/components/create-workflow-modal.tsx b/apps/dashboard/src/components/create-workflow-modal.tsx index be35d558c4b..641bcb84d3c 100644 --- a/apps/dashboard/src/components/create-workflow-modal.tsx +++ b/apps/dashboard/src/components/create-workflow-modal.tsx @@ -2,6 +2,7 @@ import { standardSchemaResolver } from '@hookform/resolvers/standard-schema'; import { AiAgentTypeEnum, AiResourceTypeEnum, DuplicateWorkflowDto } from '@novu/shared'; import * as Sentry from '@sentry/react'; +import { useQuery } from '@tanstack/react-query'; import { ChatOnDataCallback, generateId, UIMessage } from 'ai'; import { motion } from 'motion/react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; @@ -12,10 +13,12 @@ import { RiCloseLine, RiLoader3Line, RiLoader4Fill, + RiLoopLeftLine, RiRouteFill, } from 'react-icons/ri'; import { useNavigate } from 'react-router-dom'; import { z } from 'zod'; +import { fetchWorkflowSuggestions, WorkflowSuggestionResponse } from '@/api/ai'; import { Sparkling } from '@/components/icons/sparkling'; import { Button } from '@/components/primitives/button'; import { CompactButton } from '@/components/primitives/button-compact'; @@ -46,6 +49,7 @@ import { useDuplicateWorkflow } from '@/hooks/use-duplicate-workflow'; import { useFetchWorkflow } from '@/hooks/use-fetch-workflow'; import { useFormProtection } from '@/hooks/use-form-protection'; import { useTelemetry } from '@/hooks/use-telemetry'; +import { QueryKeys } from '@/utils/query-keys'; import { buildRoute, ROUTES } from '@/utils/routes'; import { TelemetryEvent } from '@/utils/telemetry'; import { Badge } from './primitives/badge'; @@ -61,13 +65,6 @@ export type WorkflowCreatedEvent = { type CreateWorkflowTab = 'guided' | 'manual'; -const WORKFLOW_SUGGESTIONS = [ - 'Welcome email workflow', - 'Order confirmation workflow', - 'Payment failed', - 'Password reset workflow', -]; - export function CreateWorkflowModal({ mode, workflowId }: { mode: 'create' | 'duplicate'; workflowId?: string }) { const navigate = useNavigate(); const { currentEnvironment } = useEnvironment(); @@ -362,6 +359,7 @@ type GenerationStep = { function GuidedModeContent({ onSubmit, isGenerating, error }: GuidedModeContentProps) { const track = useTelemetry(); + const { currentEnvironment } = useEnvironment(); const form = useForm({ resolver: standardSchemaResolver(schema), defaultValues: { @@ -369,6 +367,31 @@ function GuidedModeContent({ onSubmit, isGenerating, error }: GuidedModeContentP }, }); + const refreshRef = useRef(false); + const { + data: suggestions, + isLoading: isLoadingSuggestions, + isFetching: isFetchingSuggestions, + refetch: refetchSuggestions, + } = useQuery({ + queryKey: [QueryKeys.fetchWorkflowSuggestions, currentEnvironment?._id], + queryFn: () => { + if (!currentEnvironment) throw new Error('Environment not loaded'); + + const shouldRefresh = refreshRef.current; + refreshRef.current = false; + + return fetchWorkflowSuggestions({ environment: currentEnvironment, refresh: shouldRefresh }); + }, + enabled: !!currentEnvironment, + staleTime: 24 * 60 * 60 * 1000, + }); + + function handleRefreshSuggestions() { + refreshRef.current = true; + refetchSuggestions(); + } + const [animatedStepIndex, setAnimatedStepIndex] = useState(-1); useEffect(() => { @@ -394,9 +417,9 @@ function GuidedModeContent({ onSubmit, isGenerating, error }: GuidedModeContentP } }, [error]); - function handleSuggestionClick(suggestion: string) { - track(TelemetryEvent.COPILOT_SUGGESTION_CLICKED, { suggestion }); - form.setValue('prompt', suggestion); + function handleSuggestionClick(suggestion: WorkflowSuggestionResponse) { + track(TelemetryEvent.COPILOT_SUGGESTION_CLICKED, { suggestion: suggestion.title }); + form.setValue('prompt', suggestion.examplePrompt); } const header = useMemo( @@ -486,25 +509,29 @@ function GuidedModeContent({ onSubmit, isGenerating, error }: GuidedModeContentP {header}
- {WORKFLOW_SUGGESTIONS.map((suggestion) => ( - - ))} - {/* + ))} +
{ const { currentEnvironment } = useEnvironment(); const { tags } = useTags(); + // fetch workflow suggestions on the page visit to populate quicker + useQuery({ + queryKey: [QueryKeys.fetchWorkflowSuggestions, currentEnvironment?._id], + queryFn: () => { + if (!currentEnvironment) throw new Error('Environment not loaded'); + + return fetchWorkflowSuggestions({ environment: currentEnvironment }); + }, + enabled: !!currentEnvironment, + staleTime: 24 * 60 * 60 * 1000, + }); const queryParam = searchParams.get('query') || ''; const hasActiveFilters = diff --git a/apps/dashboard/src/utils/query-keys.ts b/apps/dashboard/src/utils/query-keys.ts index 587758f0a9a..3761e79fd1b 100644 --- a/apps/dashboard/src/utils/query-keys.ts +++ b/apps/dashboard/src/utils/query-keys.ts @@ -39,4 +39,5 @@ export const QueryKeys = Object.freeze({ fetchEnvironmentVariable: 'fetchEnvironmentVariable', fetchEnvironmentVariableUsage: 'fetchEnvironmentVariableUsage', stepResolversCount: 'stepResolversCount', + fetchWorkflowSuggestions: 'fetchWorkflowSuggestions', }); diff --git a/libs/application-generic/src/services/in-memory-lru-cache/in-memory-lru-cache.store.ts b/libs/application-generic/src/services/in-memory-lru-cache/in-memory-lru-cache.store.ts index a4dcdb320f5..aa35492c780 100644 --- a/libs/application-generic/src/services/in-memory-lru-cache/in-memory-lru-cache.store.ts +++ b/libs/application-generic/src/services/in-memory-lru-cache/in-memory-lru-cache.store.ts @@ -5,6 +5,7 @@ const MS_PER_SECOND = 1000; const THIRTY_SECONDS_MS = MS_PER_SECOND * 30; const ONE_MINUTE_MS = MS_PER_SECOND * 60; const ONE_HOUR_MS = ONE_MINUTE_MS * 60; +const ONE_DAY_MS = ONE_HOUR_MS * 24; export enum InMemoryLRUCacheStore { WORKFLOW = 'workflow', @@ -15,6 +16,7 @@ export enum InMemoryLRUCacheStore { VALIDATOR = 'validator', ACTIVE_WORKFLOWS = 'active-workflows', WORKFLOW_PREFERENCES = 'workflow-preferences', + AI_SUGGESTIONS = 'ai-suggestions', } export type WorkflowCacheData = NotificationTemplateEntity | null; @@ -25,6 +27,7 @@ export type ApiKeyUserCacheData = UserSessionData | null; export type ValidatorCacheData = unknown; export type ActiveWorkflowsCacheData = NotificationTemplateEntity[]; export type WorkflowPreferencesCacheData = [PreferencesEntity | null, PreferencesEntity | null]; +export type AiSuggestionsCacheData = unknown[]; export type CacheStoreDataTypeMap = { [InMemoryLRUCacheStore.WORKFLOW]: WorkflowCacheData; @@ -35,6 +38,7 @@ export type CacheStoreDataTypeMap = { [InMemoryLRUCacheStore.VALIDATOR]: ValidatorCacheData; [InMemoryLRUCacheStore.ACTIVE_WORKFLOWS]: ActiveWorkflowsCacheData; [InMemoryLRUCacheStore.WORKFLOW_PREFERENCES]: WorkflowPreferencesCacheData; + [InMemoryLRUCacheStore.AI_SUGGESTIONS]: AiSuggestionsCacheData; }; export type StoreConfig = { @@ -86,4 +90,10 @@ export const STORE_CONFIGS: Record = { ttl: ONE_MINUTE_MS, featureFlagComponent: 'workflow-preferences', }, + [InMemoryLRUCacheStore.AI_SUGGESTIONS]: { + max: 1000, + ttl: ONE_DAY_MS, + featureFlagComponent: 'ai-suggestions', + skipFeatureFlag: true, + }, }; diff --git a/libs/dal/src/repositories/organization/organization.entity.ts b/libs/dal/src/repositories/organization/organization.entity.ts index 7077adebc35..b1650490b7b 100644 --- a/libs/dal/src/repositories/organization/organization.entity.ts +++ b/libs/dal/src/repositories/organization/organization.entity.ts @@ -1,4 +1,4 @@ -import { ApiServiceLevelEnum, IOrganizationEntity, ProductUseCases } from '@novu/shared'; +import { ApiServiceLevelEnum, IndustryEnum, IOrganizationEntity, ProductUseCases } from '@novu/shared'; export class OrganizationEntity implements IOrganizationEntity { _id: string; @@ -23,6 +23,8 @@ export class OrganizationEntity implements IOrganizationEntity { productUseCases?: ProductUseCases; + industry?: IndustryEnum; + language?: string[]; removeNovuBranding?: boolean; diff --git a/libs/dal/src/repositories/organization/organization.schema.ts b/libs/dal/src/repositories/organization/organization.schema.ts index d00ca05e3fd..bfd476a6187 100644 --- a/libs/dal/src/repositories/organization/organization.schema.ts +++ b/libs/dal/src/repositories/organization/organization.schema.ts @@ -1,4 +1,4 @@ -import { ApiServiceLevelEnum } from '@novu/shared'; +import { ApiServiceLevelEnum, IndustryEnum } from '@novu/shared'; import mongoose, { Schema } from 'mongoose'; import { schemaOptions } from '../schema-default.options'; @@ -67,6 +67,10 @@ const organizationSchema = new Schema( default: false, }, }, + industry: { + type: Schema.Types.String, + enum: IndustryEnum, + }, externalId: Schema.Types.String, stripeCustomerId: Schema.Types.String, }, diff --git a/packages/shared/src/dto/organization/update-external-organization.dto.ts b/packages/shared/src/dto/organization/update-external-organization.dto.ts index a250aa737cf..ff2565b0212 100644 --- a/packages/shared/src/dto/organization/update-external-organization.dto.ts +++ b/packages/shared/src/dto/organization/update-external-organization.dto.ts @@ -1,4 +1,4 @@ -import { ChannelTypeEnum, JobTitleEnum, OrganizationTypeEnum } from '../../types'; +import { ChannelTypeEnum, IndustryEnum, JobTitleEnum, OrganizationTypeEnum } from '../../types'; export type UpdateExternalOrganizationDto = { jobTitle?: JobTitleEnum; @@ -8,4 +8,5 @@ export type UpdateExternalOrganizationDto = { companySize?: string; organizationType?: OrganizationTypeEnum; useCases?: ChannelTypeEnum[]; + industry?: IndustryEnum; }; diff --git a/packages/shared/src/entities/organization/organization.interface.ts b/packages/shared/src/entities/organization/organization.interface.ts index ca228304058..16a6ad2f913 100644 --- a/packages/shared/src/entities/organization/organization.interface.ts +++ b/packages/shared/src/entities/organization/organization.interface.ts @@ -1,4 +1,4 @@ -import { ApiServiceLevelEnum, ProductUseCases } from '../../types'; +import { ApiServiceLevelEnum, IndustryEnum, ProductUseCases } from '../../types'; export interface IOrganizationEntity { _id: string; @@ -17,6 +17,7 @@ export interface IOrganizationEntity { targetLocales?: string[]; domain?: string; productUseCases?: ProductUseCases; + industry?: IndustryEnum; language?: string[]; removeNovuBranding?: boolean; createdAt: string; diff --git a/packages/shared/src/types/organization.ts b/packages/shared/src/types/organization.ts index 3de6e2ec090..be803d89fde 100644 --- a/packages/shared/src/types/organization.ts +++ b/packages/shared/src/types/organization.ts @@ -23,6 +23,32 @@ export enum ProductUseCasesEnum { export type ProductUseCases = Partial>; +export enum IndustryEnum { + ECOMMERCE = 'ecommerce', + FINTECH = 'fintech', + SAAS = 'saas', + HEALTHCARE = 'healthcare', + EDUCATION = 'education', + MEDIA = 'media', + MARKETPLACE = 'marketplace', + GAMING = 'gaming', + TRAVEL = 'travel', + REAL_ESTATE = 'real_estate', + LOGISTICS = 'logistics', + FOOD_AND_BEVERAGE = 'food_and_beverage', + INSURANCE = 'insurance', + GOVERNMENT = 'government', + NON_PROFIT = 'non_profit', + TELECOMMUNICATIONS = 'telecommunications', + RETAIL = 'retail', + AUTOMOTIVE = 'automotive', + CONSTRUCTION = 'construction', + ENERGY = 'energy', + AGRICULTURE = 'agriculture', + LEGAL = 'legal', + OTHER = 'other', +} + export type OrganizationPublicMetadata = { externalOrgId?: string; domain?: string; @@ -30,4 +56,5 @@ export type OrganizationPublicMetadata = { language?: string[]; defaultLocale?: string; companySize?: string; + industry?: IndustryEnum; };