diff --git a/wavefront/client/src/api/app-user-service.ts b/wavefront/client/src/api/app-user-service.ts index 0bd3f471..87ae8cbc 100644 --- a/wavefront/client/src/api/app-user-service.ts +++ b/wavefront/client/src/api/app-user-service.ts @@ -22,8 +22,8 @@ export class AppUserService { /** * List users with access to an app (owners only) */ - async listAppUsers(appId: string): Promise> { - return this.http.get(`/v1/apps/${appId}/users`); + async listAppUsers(): Promise> { + return this.http.get(`/v1/:appId/floware/v1/users`); } /** diff --git a/wavefront/client/src/hooks/data/fetch-hooks.ts b/wavefront/client/src/hooks/data/fetch-hooks.ts index c49feb3d..2d63def9 100644 --- a/wavefront/client/src/hooks/data/fetch-hooks.ts +++ b/wavefront/client/src/hooks/data/fetch-hooks.ts @@ -28,6 +28,7 @@ import { getApiServiceQueryFn, getApiServicesQueryFn, getAppByIdFn, + getAppUsersQueryFn, getAuthenticatorQueryFn, getAuthenticatorsQueryFn, getCurrentUserQueryFn, @@ -54,7 +55,7 @@ import { getToolsQueryFn, getTtsConfigQueryFn, getTtsConfigsQueryFn, - getUsersQueryFn, + getConsoleUsersQueryFn, getVoiceAgentQueryFn, getVoiceAgentToolQueryFn, getVoiceAgentToolsQueryFn, @@ -74,6 +75,7 @@ import { getApiServiceKey, getApiServicesKey, getAppByIdKey, + getAppUsersKey, getAuthenticatorKey, getAuthenticatorsKey, getCurrentUserKey, @@ -100,7 +102,7 @@ import { getToolsKey, getTtsConfigKey, getTtsConfigsKey, - getUsersKey, + getConsoleUsersKey, getVoiceAgentKey, getVoiceAgentToolKey, getVoiceAgentToolsKey, @@ -417,8 +419,12 @@ export const useGetAppById = (appId: string, enabled: boolean = true): UseQueryR return useQueryInit(getAppByIdKey(appId), () => getAppByIdFn(appId), enabled); }; -export const useGetUsers = (): UseQueryResult => { - return useQueryInit(getUsersKey(), getUsersQueryFn, true); +export const useGetAppUsers = (appId: string | undefined): UseQueryResult => { + return useQueryInit(getAppUsersKey(appId || ''), () => getAppUsersQueryFn(), !!appId); +}; + +export const useGetConsoleUsers = (): UseQueryResult => { + return useQueryInit(getConsoleUsersKey(), getConsoleUsersQueryFn, true); }; // Voice Agent Tools Hooks diff --git a/wavefront/client/src/hooks/data/mutation-hooks.ts b/wavefront/client/src/hooks/data/mutation-hooks.ts index 287bec1f..1482f0e2 100644 --- a/wavefront/client/src/hooks/data/mutation-hooks.ts +++ b/wavefront/client/src/hooks/data/mutation-hooks.ts @@ -1,5 +1,5 @@ import { QueryClient, useMutation, useQueryClient } from '@tanstack/react-query'; -import { getAgentKey, getAgentsKey, getAppByIdKey, getUserKey, getUsersKey } from './query-keys'; +import { getAgentKey, getAgentsKey, getAppByIdKey, getConsoleUsersKey, getUserKey } from './query-keys'; import { createUserMutationFn, deleteAgentMutationFn, @@ -90,7 +90,7 @@ export const useCreateUser = () => { mutationFn: createUserMutationFn, onSuccess: () => { notifySuccess('User created successfully'); - queryClient.invalidateQueries({ queryKey: getUsersKey() }); + queryClient.invalidateQueries({ queryKey: getConsoleUsersKey() }); }, onError: (error) => { console.error('Error creating user:', error); @@ -108,7 +108,7 @@ export const useUpdateUser = (userId: string | undefined) => { mutationFn: updateUserMutationFn, onSuccess: () => { notifySuccess('User updated successfully'); - queryClient.invalidateQueries({ queryKey: getUsersKey() }); + queryClient.invalidateQueries({ queryKey: getConsoleUsersKey() }); if (userId) { queryClient.invalidateQueries({ queryKey: getUserKey(userId) }); } @@ -129,7 +129,7 @@ export const useDeleteUser = () => { mutationFn: deleteUserMutationFn, onSuccess: () => { notifySuccess('User deleted successfully'); - queryClient.invalidateQueries({ queryKey: getUsersKey() }); + queryClient.invalidateQueries({ queryKey: getConsoleUsersKey() }); }, onError: (error) => { console.error('Error deleting user:', error); diff --git a/wavefront/client/src/hooks/data/query-functions.ts b/wavefront/client/src/hooks/data/query-functions.ts index 4b463c6f..65aa4921 100644 --- a/wavefront/client/src/hooks/data/query-functions.ts +++ b/wavefront/client/src/hooks/data/query-functions.ts @@ -396,7 +396,15 @@ const getAgentToolsQueryFn = async (agentId: string): Promise => { +const getAppUsersQueryFn = async (): Promise => { + const response = await floConsoleService.appUserService.listAppUsers(); + if (response.data?.data?.users && Array.isArray(response.data.data.users)) { + return response.data.data.users; + } + return []; +}; + +const getConsoleUsersQueryFn = async (): Promise => { const response = await floConsoleService.userService.listUsers(); if (response.data?.data?.users && Array.isArray(response.data.data.users)) { return response.data.data.users; @@ -414,6 +422,7 @@ export { getApiServiceQueryFn, getApiServicesQueryFn, getAppByIdFn, + getAppUsersQueryFn, getAuthenticatorQueryFn, getAuthenticatorsQueryFn, getCurrentUserQueryFn, @@ -440,7 +449,7 @@ export { getToolsQueryFn, getTtsConfigQueryFn, getTtsConfigsQueryFn, - getUsersQueryFn, + getConsoleUsersQueryFn, getVoiceAgentQueryFn, getVoiceAgentToolQueryFn, getVoiceAgentToolsQueryFn, diff --git a/wavefront/client/src/hooks/data/query-keys.ts b/wavefront/client/src/hooks/data/query-keys.ts index b7cddee6..021a938b 100644 --- a/wavefront/client/src/hooks/data/query-keys.ts +++ b/wavefront/client/src/hooks/data/query-keys.ts @@ -62,7 +62,8 @@ const getPipelinesKey = (appId: string, statusFilter?: string) => { const getPipelineKey = (appId: string, pipelineId: string) => ['pipeline', appId, pipelineId]; const getPipelineFilesKey = (appId: string, pipelineId: string) => ['pipeline-files', appId, pipelineId]; const getAppByIdKey = (appId: string) => ['app-by-id', appId]; -const getUsersKey = () => ['users']; +const getAppUsersKey = (appId: string) => ['app-users', appId]; +const getConsoleUsersKey = () => ['console-users']; const getUserKey = (userId: string) => ['user', userId]; const getVoiceAgentToolsKey = (appId: string) => ['voice-agent-tools', appId]; const getVoiceAgentToolKey = (appId: string, toolId: string) => ['voice-agent-tool', appId, toolId]; @@ -105,7 +106,7 @@ export { getTtsConfigKey, getTtsConfigsKey, getUserKey, - getUsersKey, + getConsoleUsersKey, getVoiceAgentKey, getVoiceAgentToolKey, getVoiceAgentToolsKey, @@ -114,4 +115,5 @@ export { getWorkflowRunsKey, getWorkflowsKey, getAppByIdKey, + getAppUsersKey, }; diff --git a/wavefront/client/src/pages/apps/[appId]/datasources/ScheduleEmailAlertDialog.tsx b/wavefront/client/src/pages/apps/[appId]/datasources/ScheduleEmailAlertDialog.tsx index aefb1cb8..54e7b76f 100644 --- a/wavefront/client/src/pages/apps/[appId]/datasources/ScheduleEmailAlertDialog.tsx +++ b/wavefront/client/src/pages/apps/[appId]/datasources/ScheduleEmailAlertDialog.tsx @@ -7,23 +7,84 @@ import { DialogHeader, DialogTitle, } from '@app/components/ui/dialog'; +import { Badge } from '@app/components/ui/badge'; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from '@app/components/ui/command'; import { Input } from '@app/components/ui/input'; +import { Popover, PopoverContent, PopoverTrigger } from '@app/components/ui/popover'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@app/components/ui/select'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@app/components/ui/tabs'; import { Textarea } from '@app/components/ui/textarea'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@app/components/ui/tooltip'; +import { useGetAppUsers } from '@app/hooks'; +import { cn } from '@app/lib/utils'; import { useNotifyStore } from '@app/store'; -import { ScheduledJob } from '@app/types/scheduled-job'; +import { IUser } from '@app/types/user'; +import { ScheduledJob, ColumnStyleConfig } from '@app/types/scheduled-job'; import floConsoleService from '@app/api'; +import { Check, ChevronDown, Info, X } from 'lucide-react'; import { useEffect, useMemo, useState } from 'react'; interface ScheduleEmailAlertDialogProps { isOpen: boolean; + appId: string; datasourceId: string; queryId: string; onOpenChange: (open: boolean) => void; } +const normalizeUserId = (id: string) => id.trim().toLowerCase(); + +const formatUserLabel = (user: IUser) => `${user.first_name} ${user.last_name} (${user.email})`; + +const extractRecipientUserIdsFromPayload = (payload: Record): string[] => { + const rawIds = payload.recipient_user_ids; + const ids: string[] = []; + if (Array.isArray(rawIds)) { + for (const item of rawIds) { + const id = String(item).trim(); + if (id) ids.push(id); + } + } else if (typeof rawIds === 'string' && rawIds.trim()) { + ids.push(rawIds.trim()); + } + return ids; +}; + +const resolveUsersFromRecipientIds = (ids: string[], users: IUser[]): IUser[] => { + const resolved: IUser[] = []; + const seen = new Set(); + for (const id of ids) { + const user = users.find((u) => normalizeUserId(u.id) === normalizeUserId(id)); + if (user && !seen.has(normalizeUserId(user.id))) { + seen.add(normalizeUserId(user.id)); + resolved.push(user); + } + } + return resolved; +}; + +const COLUMN_STYLES_PLACEHOLDER = `[ + { + "column": "Total calls attempted", + "rules": [ + { "op": "eq", "value": 0, "fill": "light_red" }, + { "op": "lt", "value": 160, "fill": "light_yellow" }, + { "op": "lt", "value": 225, "fill": "light_green" }, + { "op": "gte", "value": 225, "fill": "dark_green" } + ] + } +]`; + const ScheduleEmailAlertDialog: React.FC = ({ isOpen, + appId, datasourceId, queryId, onOpenChange, @@ -33,9 +94,75 @@ const ScheduleEmailAlertDialog: React.FC = ({ const [loadingJobs, setLoadingJobs] = useState(false); const [cronExpr, setCronExpr] = useState('0 9 * * *'); const [timezone, setTimezone] = useState('Asia/Kolkata'); - const [recipientsText, setRecipientsText] = useState(''); + const [selectedRecipientUserIds, setSelectedRecipientUserIds] = useState([]); + const [recipientsSelectOpen, setRecipientsSelectOpen] = useState(false); + const { data: appUsers = [], isLoading: appUsersLoading } = useGetAppUsers(appId); + + const selectedRecipientUsers = useMemo( + () => resolveUsersFromRecipientIds(selectedRecipientUserIds, appUsers), + [selectedRecipientUserIds, appUsers] + ); + + const isRecipientSelected = (userId: string) => + selectedRecipientUserIds.some((id) => normalizeUserId(id) === normalizeUserId(userId)); + + const toggleRecipientUser = (userId: string) => { + setSelectedRecipientUserIds((prev) => + isRecipientSelected(userId) + ? prev.filter((id) => normalizeUserId(id) !== normalizeUserId(userId)) + : [...prev, userId] + ); + }; + + const removeRecipientUser = (userId: string) => { + setSelectedRecipientUserIds((prev) => prev.filter((id) => normalizeUserId(id) !== normalizeUserId(userId))); + }; + + const applyJobToForm = (job: ScheduledJob) => { + setEditingJobId(job.id); + setCronExpr(job.cron_expr || '0 9 * * *'); + setTimezone(job.timezone || 'Asia/Kolkata'); + setMaxRetries(String(job.max_retries ?? 3)); + const payload = (job.payload || {}) as Record; + setSelectedRecipientUserIds(extractRecipientUserIdsFromPayload(payload)); + setSubject(typeof payload.subject === 'string' ? payload.subject : ''); + setEmailContent(typeof payload.email_content === 'string' ? payload.email_content : ''); + const paramsValue = payload.params; + const dateRangeValue = payload.date_range; + if ( + dateRangeValue === 'last_day' || + dateRangeValue === 'last_hour' || + dateRangeValue === 'last_7_days' || + dateRangeValue === 'last_30_days' + ) { + setDateRange(dateRangeValue); + } else { + setDateRange('none'); + } + setStartDateParamKey(typeof payload.start_date_param === 'string' ? payload.start_date_param : 'start_date'); + setEndDateParamKey(typeof payload.end_date_param === 'string' ? payload.end_date_param : 'end_date'); + if (paramsValue && typeof paramsValue === 'object' && !Array.isArray(paramsValue)) { + setQueryParamsJson(JSON.stringify(paramsValue, null, 2)); + } else { + setQueryParamsJson(''); + } + const columnStylesValue = payload.column_styles; + if (Array.isArray(columnStylesValue) && columnStylesValue.length > 0) { + setColumnStylesJson(JSON.stringify(columnStylesValue, null, 2)); + } else { + setColumnStylesJson(''); + } + setError(''); + }; + + const getJobRecipientLabels = (job: ScheduledJob): string[] => { + const payload = (job.payload || {}) as Record; + return resolveUsersFromRecipientIds(extractRecipientUserIdsFromPayload(payload), appUsers).map(formatUserLabel); + }; const [subject, setSubject] = useState(''); + const [emailContent, setEmailContent] = useState(''); const [queryParamsJson, setQueryParamsJson] = useState(''); + const [columnStylesJson, setColumnStylesJson] = useState(''); const [dateRange, setDateRange] = useState<'none' | 'last_day' | 'last_hour' | 'last_7_days' | 'last_30_days'>( 'none' ); @@ -43,29 +170,24 @@ const ScheduleEmailAlertDialog: React.FC = ({ const [endDateParamKey, setEndDateParamKey] = useState('end_date'); const [maxRetries, setMaxRetries] = useState('3'); const [editingJobId, setEditingJobId] = useState(null); + const [activeTab, setActiveTab] = useState<'schedule' | 'email'>('schedule'); const [saving, setSaving] = useState(false); const [error, setError] = useState(''); - const recipients = useMemo( - () => - recipientsText - .split(',') - .map((email) => email.trim()) - .filter(Boolean), - [recipientsText] - ); - const resetForm = () => { setCronExpr('0 9 * * *'); setTimezone('Asia/Kolkata'); - setRecipientsText(''); + setSelectedRecipientUserIds([]); setSubject(''); + setEmailContent(''); setQueryParamsJson(''); + setColumnStylesJson(''); setDateRange('none'); setStartDateParamKey('start_date'); setEndDateParamKey('end_date'); setMaxRetries('3'); setEditingJobId(null); + setActiveTab('schedule'); setError(''); }; @@ -107,18 +229,22 @@ const ScheduleEmailAlertDialog: React.FC = ({ const retries = Number(maxRetries); if (!cronExpr.trim()) { setError('Cron expression is required'); + setActiveTab('schedule'); return; } if (!timezone.trim()) { setError('Timezone is required'); + setActiveTab('schedule'); return; } - if (recipients.length === 0) { - setError('At least one recipient email is required'); + if (selectedRecipientUserIds.length === 0) { + setError('At least one recipient user is required'); + setActiveTab('email'); return; } if (!Number.isInteger(retries) || retries < 0 || retries > 10) { setError('Max retries must be an integer between 0 and 10'); + setActiveTab('schedule'); return; } let parsedParams: Record | undefined; @@ -127,11 +253,30 @@ const ScheduleEmailAlertDialog: React.FC = ({ const value = JSON.parse(queryParamsJson); if (typeof value !== 'object' || value === null || Array.isArray(value)) { setError('Query params must be a JSON object'); + setActiveTab('schedule'); return; } parsedParams = value as Record; } catch { setError('Query params must be valid JSON (object)'); + setActiveTab('schedule'); + return; + } + } + + let parsedColumnStyles: ColumnStyleConfig[] | undefined; + if (columnStylesJson.trim()) { + try { + const value = JSON.parse(columnStylesJson); + if (!Array.isArray(value)) { + setError('Column styles must be a JSON array'); + setActiveTab('email'); + return; + } + parsedColumnStyles = value as ColumnStyleConfig[]; + } catch { + setError('Column styles must be valid JSON (array)'); + setActiveTab('email'); return; } } @@ -147,8 +292,10 @@ const ScheduleEmailAlertDialog: React.FC = ({ payload: { datasource_id: datasourceId, query_id: queryId, - recipients, + recipient_user_ids: selectedRecipientUserIds, subject: subject.trim() || undefined, + email_content: emailContent.trim() || undefined, + column_styles: parsedColumnStyles, date_range: dateRange === 'none' ? undefined : dateRange, start_date_param: dateRange === 'none' ? undefined : startDateParamKey.trim() || 'start_date', end_date_param: dateRange === 'none' ? undefined : endDateParamKey.trim() || 'end_date', @@ -156,6 +303,7 @@ const ScheduleEmailAlertDialog: React.FC = ({ }, }); notifySuccess('Schedule updated successfully'); + await fetchJobs(); } else { await floConsoleService.scheduledJobService.createScheduledJob({ job_type: 'email_dynamic_query', @@ -165,8 +313,10 @@ const ScheduleEmailAlertDialog: React.FC = ({ payload: { datasource_id: datasourceId, query_id: queryId, - recipients, + recipient_user_ids: selectedRecipientUserIds, subject: subject.trim() || undefined, + email_content: emailContent.trim() || undefined, + column_styles: parsedColumnStyles, date_range: dateRange === 'none' ? undefined : dateRange, start_date_param: dateRange === 'none' ? undefined : startDateParamKey.trim() || 'start_date', end_date_param: dateRange === 'none' ? undefined : endDateParamKey.trim() || 'end_date', @@ -174,9 +324,9 @@ const ScheduleEmailAlertDialog: React.FC = ({ }, }); notifySuccess('Email alert scheduled successfully'); + resetForm(); + await fetchJobs(); } - resetForm(); - await fetchJobs(); } catch { setError('Unable to create schedule. Please verify the details and try again.'); } finally { @@ -185,33 +335,8 @@ const ScheduleEmailAlertDialog: React.FC = ({ }; const handleEdit = (job: ScheduledJob) => { - setEditingJobId(job.id); - setCronExpr(job.cron_expr || '0 9 * * *'); - setTimezone(job.timezone || 'Asia/Kolkata'); - setMaxRetries(String(job.max_retries ?? 3)); - const payload = (job.payload || {}) as Record; - const recipients = Array.isArray(payload.recipients) ? payload.recipients : []; - setRecipientsText(recipients.map((item) => String(item)).join(', ')); - setSubject(typeof payload.subject === 'string' ? payload.subject : ''); - const paramsValue = payload.params; - const dateRangeValue = payload.date_range; - if ( - dateRangeValue === 'last_day' || - dateRangeValue === 'last_hour' || - dateRangeValue === 'last_7_days' || - dateRangeValue === 'last_30_days' - ) { - setDateRange(dateRangeValue); - } else { - setDateRange('none'); - } - setStartDateParamKey(typeof payload.start_date_param === 'string' ? payload.start_date_param : 'start_date'); - setEndDateParamKey(typeof payload.end_date_param === 'string' ? payload.end_date_param : 'end_date'); - if (paramsValue && typeof paramsValue === 'object' && !Array.isArray(paramsValue)) { - setQueryParamsJson(JSON.stringify(paramsValue, null, 2)); - } else { - setQueryParamsJson(''); - } + applyJobToForm(job); + setActiveTab('email'); }; const handleDelete = async (jobId: string) => { @@ -232,7 +357,7 @@ const ScheduleEmailAlertDialog: React.FC = ({ return ( - + Schedule Email Alert Create a scheduled query email for this dynamic query. @@ -261,7 +386,10 @@ const ScheduleEmailAlertDialog: React.FC = ({ {jobs.map((job) => (

@@ -274,6 +402,15 @@ const ScheduleEmailAlertDialog: React.FC = ({ Next Run:{' '} {job.next_run_at ? new Date(job.next_run_at).toLocaleString() : '-'}

+

+ Recipients:{' '} + {appUsersLoading + ? 'Loading...' + : (() => { + const labels = getJobRecipientLabels(job); + return labels.length > 0 ? labels.join(', ') : 'None'; + })()} +

-
-
-

Datasource ID

- -
-
-

Query ID

- -
-
+ setActiveTab(value as 'schedule' | 'email')}> + + Schedule + Email + -
-
-

Cron expression

- setCronExpr(e.target.value)} placeholder="0 9 * * *" /> -
-
-

Timezone

- setTimezone(e.target.value)} placeholder="Asia/Kolkata" /> -
-
+ +
+
+

Datasource ID

+ +
+
+

Query ID

+ +
+
-
-
-

Max retries

- setMaxRetries(e.target.value)} placeholder="3" /> -
-
-

Subject (optional)

- setSubject(e.target.value)} placeholder="Daily report" /> -
-
+
+
+

Cron expression

+ setCronExpr(e.target.value)} placeholder="0 9 * * *" /> +
+
+

Timezone

+ setTimezone(e.target.value)} placeholder="Asia/Kolkata" /> +
+
+

Max retries

+ setMaxRetries(e.target.value)} placeholder="3" /> +
+
-
-

Recipients (comma-separated emails)

-