Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .source
24 changes: 24 additions & 0 deletions apps/dashboard/src/api/ai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
AiMessageRoleEnum,
AiResourceTypeEnum,
IEnvironment,
StepTypeEnum,
WorkflowResponseDto,
} from '@novu/shared';
import { UIMessage } from 'ai';
Expand Down Expand Up @@ -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<WorkflowSuggestionResponse[]> {
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,
Expand Down
75 changes: 51 additions & 24 deletions apps/dashboard/src/components/create-workflow-modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -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';
Expand All @@ -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();
Expand Down Expand Up @@ -362,13 +359,39 @@ type GenerationStep = {

function GuidedModeContent({ onSubmit, isGenerating, error }: GuidedModeContentProps) {
const track = useTelemetry();
const { currentEnvironment } = useEnvironment();
const form = useForm({
resolver: standardSchemaResolver(schema),
defaultValues: {
prompt: '',
},
});

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(() => {
Expand All @@ -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(
Expand Down Expand Up @@ -486,25 +509,29 @@ function GuidedModeContent({ onSubmit, isGenerating, error }: GuidedModeContentP
{header}

<div className="flex flex-wrap items-center gap-2 mt-8">
{WORKFLOW_SUGGESTIONS.map((suggestion) => (
<button
key={suggestion}
type="button"
className="cursor-pointer"
onClick={() => handleSuggestionClick(suggestion)}
>
<Tag className="rounded-full" variant="stroke" icon={<RiRouteFill className="text-feature" />}>
{suggestion}
</Tag>
</button>
))}
{/* <Button
{isLoadingSuggestions || isFetchingSuggestions
? Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-6 w-36 rounded-full" />)
: suggestions?.map((suggestion) => (
<button
key={suggestion.id}
type="button"
className="cursor-pointer"
onClick={() => handleSuggestionClick(suggestion)}
>
<Tag className="rounded-full" variant="stroke" icon={<RiRouteFill className="text-feature" />}>
{suggestion.title}
</Tag>
</button>
))}
<Button
className="cursor-pointer h-6 [&_svg]:size-2.5"
variant="secondary"
mode="ghost"
size="2xs"
trailingIcon={RiLoopLeftLine}
/> */}
disabled={isFetchingSuggestions}
onClick={handleRefreshSuggestions}
/>
</div>
<Form {...form}>
<FormRoot
Expand Down
14 changes: 14 additions & 0 deletions apps/dashboard/src/pages/workflows.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { DirectionEnum, EnvironmentTypeEnum, PermissionsEnum, WorkflowStatusEnum } from '@novu/shared';
import { useQuery } from '@tanstack/react-query';
import { useCallback, useEffect, useMemo } from 'react';
import { useForm } from 'react-hook-form';
import {
Expand All @@ -10,6 +11,7 @@ import {
RiRouteFill,
} from 'react-icons/ri';
import { Outlet, useNavigate, useParams, useSearchParams } from 'react-router-dom';
import { fetchWorkflowSuggestions } from '@/api/ai';
import { DashboardLayout } from '@/components/dashboard-layout';
import { PageMeta } from '@/components/page-meta';
import { Button } from '@/components/primitives/button';
Expand Down Expand Up @@ -37,6 +39,7 @@ import { getPersistedPageSize, usePersistedPageSize } from '@/hooks/use-persiste
import { useTags } from '@/hooks/use-tags';
import { useTelemetry } from '@/hooks/use-telemetry';
import { QuickTemplate, useTemplateStore } from '@/hooks/use-template-store';
import { QueryKeys } from '@/utils/query-keys';
import { buildRoute, ROUTES } from '@/utils/routes';
import { TelemetryEvent } from '@/utils/telemetry';

Expand Down Expand Up @@ -173,6 +176,17 @@ export const WorkflowsPage = () => {

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 =
Expand Down
1 change: 1 addition & 0 deletions apps/dashboard/src/utils/query-keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,4 +39,5 @@ export const QueryKeys = Object.freeze({
fetchEnvironmentVariable: 'fetchEnvironmentVariable',
fetchEnvironmentVariableUsage: 'fetchEnvironmentVariableUsage',
stepResolversCount: 'stepResolversCount',
fetchWorkflowSuggestions: 'fetchWorkflowSuggestions',
});
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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;
Expand All @@ -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;
Expand All @@ -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 = {
Expand Down Expand Up @@ -86,4 +90,10 @@ export const STORE_CONFIGS: Record<InMemoryLRUCacheStore, StoreConfig> = {
ttl: ONE_MINUTE_MS,
featureFlagComponent: 'workflow-preferences',
},
[InMemoryLRUCacheStore.AI_SUGGESTIONS]: {
max: 1000,
ttl: ONE_DAY_MS,
featureFlagComponent: 'ai-suggestions',
skipFeatureFlag: true,
},
};
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -23,6 +23,8 @@ export class OrganizationEntity implements IOrganizationEntity {

productUseCases?: ProductUseCases;

industry?: IndustryEnum;

language?: string[];

removeNovuBranding?: boolean;
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -67,6 +67,10 @@ const organizationSchema = new Schema<OrganizationDBModel>(
default: false,
},
},
industry: {
type: Schema.Types.String,
enum: IndustryEnum,
},
externalId: Schema.Types.String,
stripeCustomerId: Schema.Types.String,
},
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { ChannelTypeEnum, JobTitleEnum, OrganizationTypeEnum } from '../../types';
import { ChannelTypeEnum, IndustryEnum, JobTitleEnum, OrganizationTypeEnum } from '../../types';

export type UpdateExternalOrganizationDto = {
jobTitle?: JobTitleEnum;
Expand All @@ -8,4 +8,5 @@ export type UpdateExternalOrganizationDto = {
companySize?: string;
organizationType?: OrganizationTypeEnum;
useCases?: ChannelTypeEnum[];
industry?: IndustryEnum;
};
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { ApiServiceLevelEnum, ProductUseCases } from '../../types';
import { ApiServiceLevelEnum, IndustryEnum, ProductUseCases } from '../../types';

export interface IOrganizationEntity {
_id: string;
Expand All @@ -17,6 +17,7 @@ export interface IOrganizationEntity {
targetLocales?: string[];
domain?: string;
productUseCases?: ProductUseCases;
industry?: IndustryEnum;
language?: string[];
removeNovuBranding?: boolean;
createdAt: string;
Expand Down
27 changes: 27 additions & 0 deletions packages/shared/src/types/organization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,38 @@ export enum ProductUseCasesEnum {

export type ProductUseCases = Partial<Record<ProductUseCasesEnum, boolean>>;

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;
productUseCases?: ProductUseCases;
language?: string[];
defaultLocale?: string;
companySize?: string;
industry?: IndustryEnum;
};
Loading