Enhance Activepieces automation flows with restore UI and publishing
- Add "Restore Defaults" dropdown to Automations page with confirmation - Create flows in "Defaults" folder for organization - Pre-populate trigger sample data when creating/restoring flows - Auto-publish flows (lock and enable) after creation - Fix email template context variables to match template tags - Fix dark mode logo switching in Activepieces iframe - Add iframe refresh on flow restore - Auto-populate business context (name, email, phone, address) in emails 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -9,7 +9,6 @@ import { listCustomersAction } from './lib/actions/list-customers';
|
|||||||
import { sendEmailAction } from './lib/actions/send-email';
|
import { sendEmailAction } from './lib/actions/send-email';
|
||||||
import { listEmailTemplatesAction } from './lib/actions/list-email-templates';
|
import { listEmailTemplatesAction } from './lib/actions/list-email-templates';
|
||||||
import { eventCreatedTrigger, eventUpdatedTrigger, eventCancelledTrigger, eventStatusChangedTrigger, paymentReceivedTrigger, upcomingEventsTrigger } from './lib/triggers';
|
import { eventCreatedTrigger, eventUpdatedTrigger, eventCancelledTrigger, eventStatusChangedTrigger, paymentReceivedTrigger, upcomingEventsTrigger } from './lib/triggers';
|
||||||
import { API_URL } from './lib/common';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SmoothSchedule Authentication
|
* SmoothSchedule Authentication
|
||||||
|
|||||||
@@ -38,6 +38,39 @@ interface PaymentData {
|
|||||||
} | null;
|
} | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SAMPLE_PAYMENT_DATA: PaymentData = {
|
||||||
|
id: 12345,
|
||||||
|
payment_intent_id: 'pi_3QDEr5GvIfP3a7s90bcd1234',
|
||||||
|
amount: '50.00',
|
||||||
|
currency: 'usd',
|
||||||
|
type: 'deposit',
|
||||||
|
status: 'SUCCEEDED',
|
||||||
|
created_at: '2024-12-01T10:00:00Z',
|
||||||
|
completed_at: '2024-12-01T10:00:05Z',
|
||||||
|
event: {
|
||||||
|
id: 100,
|
||||||
|
title: 'Consultation with John Doe',
|
||||||
|
start_time: '2024-12-15T14:00:00Z',
|
||||||
|
end_time: '2024-12-15T15:00:00Z',
|
||||||
|
status: 'SCHEDULED',
|
||||||
|
deposit_amount: '50.00',
|
||||||
|
final_price: '200.00',
|
||||||
|
remaining_balance: '150.00',
|
||||||
|
},
|
||||||
|
service: {
|
||||||
|
id: 1,
|
||||||
|
name: 'Consultation',
|
||||||
|
price: '200.00',
|
||||||
|
},
|
||||||
|
customer: {
|
||||||
|
id: 50,
|
||||||
|
first_name: 'John',
|
||||||
|
last_name: 'Doe',
|
||||||
|
email: 'john.doe@example.com',
|
||||||
|
phone: '+1-555-0100',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export const paymentReceivedTrigger = createTrigger({
|
export const paymentReceivedTrigger = createTrigger({
|
||||||
auth: smoothScheduleAuth,
|
auth: smoothScheduleAuth,
|
||||||
name: 'payment_received',
|
name: 'payment_received',
|
||||||
@@ -78,15 +111,26 @@ export const paymentReceivedTrigger = createTrigger({
|
|||||||
queryParams['type'] = paymentType;
|
queryParams['type'] = paymentType;
|
||||||
}
|
}
|
||||||
|
|
||||||
const payments = await makeRequest<PaymentData[]>(
|
try {
|
||||||
auth,
|
const payments = await makeRequest<PaymentData[]>(
|
||||||
HttpMethod.GET,
|
auth,
|
||||||
'/payments/',
|
HttpMethod.GET,
|
||||||
undefined,
|
'/payments/',
|
||||||
queryParams
|
undefined,
|
||||||
);
|
queryParams
|
||||||
|
);
|
||||||
|
|
||||||
return payments;
|
// Return real data if available, otherwise return sample data
|
||||||
|
if (payments && payments.length > 0) {
|
||||||
|
return payments;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Fall through to sample data on error
|
||||||
|
console.error('Error fetching payments for sample data:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return static sample data if no real payments exist
|
||||||
|
return [SAMPLE_PAYMENT_DATA];
|
||||||
},
|
},
|
||||||
async run(context) {
|
async run(context) {
|
||||||
const auth = context.auth as SmoothScheduleAuth;
|
const auth = context.auth as SmoothScheduleAuth;
|
||||||
@@ -121,36 +165,5 @@ export const paymentReceivedTrigger = createTrigger({
|
|||||||
|
|
||||||
return payments;
|
return payments;
|
||||||
},
|
},
|
||||||
sampleData: {
|
sampleData: SAMPLE_PAYMENT_DATA,
|
||||||
id: 12345,
|
|
||||||
payment_intent_id: 'pi_3QDEr5GvIfP3a7s90bcd1234',
|
|
||||||
amount: '50.00',
|
|
||||||
currency: 'usd',
|
|
||||||
type: 'deposit',
|
|
||||||
status: 'SUCCEEDED',
|
|
||||||
created_at: '2024-12-01T10:00:00Z',
|
|
||||||
completed_at: '2024-12-01T10:00:05Z',
|
|
||||||
event: {
|
|
||||||
id: 100,
|
|
||||||
title: 'Consultation with John Doe',
|
|
||||||
start_time: '2024-12-15T14:00:00Z',
|
|
||||||
end_time: '2024-12-15T15:00:00Z',
|
|
||||||
status: 'SCHEDULED',
|
|
||||||
deposit_amount: '50.00',
|
|
||||||
final_price: '200.00',
|
|
||||||
remaining_balance: '150.00',
|
|
||||||
},
|
|
||||||
service: {
|
|
||||||
id: 1,
|
|
||||||
name: 'Consultation',
|
|
||||||
price: '200.00',
|
|
||||||
},
|
|
||||||
customer: {
|
|
||||||
id: 50,
|
|
||||||
first_name: 'John',
|
|
||||||
last_name: 'Doe',
|
|
||||||
email: 'john.doe@example.com',
|
|
||||||
phone: '+1-555-0100',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,15 +1,64 @@
|
|||||||
import { t } from 'i18next';
|
import { t } from 'i18next';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
import { flagsHooks } from '@/hooks/flags-hooks';
|
import { flagsHooks } from '@/hooks/flags-hooks';
|
||||||
|
import { useTheme } from '@/components/theme-provider';
|
||||||
|
|
||||||
const FullLogo = () => {
|
const FullLogo = () => {
|
||||||
const branding = flagsHooks.useWebsiteBranding();
|
const branding = flagsHooks.useWebsiteBranding();
|
||||||
|
const { theme } = useTheme();
|
||||||
|
|
||||||
|
// Track resolved theme from DOM (handles 'system' theme correctly)
|
||||||
|
const [isDark, setIsDark] = useState(() =>
|
||||||
|
document.documentElement.classList.contains('dark')
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Update when theme changes - check the actual applied class
|
||||||
|
const checkDark = () => {
|
||||||
|
setIsDark(document.documentElement.classList.contains('dark'));
|
||||||
|
};
|
||||||
|
checkDark();
|
||||||
|
|
||||||
|
// Observe class changes on documentElement
|
||||||
|
const observer = new MutationObserver((mutations) => {
|
||||||
|
for (const mutation of mutations) {
|
||||||
|
if (mutation.attributeName === 'class') {
|
||||||
|
checkDark();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
observer.observe(document.documentElement, { attributes: true });
|
||||||
|
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [theme]);
|
||||||
|
|
||||||
|
// Support dark mode by switching logo URLs
|
||||||
|
// Light logo (dark text) for light mode, dark logo (light text) for dark mode
|
||||||
|
const baseLogoUrl = branding.logos.fullLogoUrl;
|
||||||
|
|
||||||
|
// Compute the appropriate logo URL based on theme
|
||||||
|
let logoUrl = baseLogoUrl;
|
||||||
|
if (isDark) {
|
||||||
|
// Need dark logo (light text for dark background)
|
||||||
|
if (baseLogoUrl.includes('-light.svg')) {
|
||||||
|
logoUrl = baseLogoUrl.replace('-light.svg', '-dark.svg');
|
||||||
|
} else if (!baseLogoUrl.includes('-dark.svg')) {
|
||||||
|
logoUrl = baseLogoUrl.replace(/\.svg$/, '-dark.svg');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Need light logo (dark text for light background)
|
||||||
|
if (baseLogoUrl.includes('-dark.svg')) {
|
||||||
|
logoUrl = baseLogoUrl.replace('-dark.svg', '-light.svg');
|
||||||
|
}
|
||||||
|
// Otherwise use base URL as-is (assumed to be light version)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-[60px]">
|
<div className="h-[60px]">
|
||||||
<img
|
<img
|
||||||
className="h-full"
|
className="h-full"
|
||||||
src={branding.logos.fullLogoUrl}
|
src={logoUrl}
|
||||||
alt={t('logo')}
|
alt={t('logo')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -65,8 +65,8 @@ export function generateTheme({
|
|||||||
|
|
||||||
export const defaultTheme = generateTheme({
|
export const defaultTheme = generateTheme({
|
||||||
primaryColor: '#6e41e2',
|
primaryColor: '#6e41e2',
|
||||||
websiteName: 'Activepieces',
|
websiteName: 'Automation Builder',
|
||||||
fullLogoUrl: 'https://cdn.activepieces.com/brand/full-logo.png',
|
fullLogoUrl: 'https://smoothschedule.nyc3.digitaloceanspaces.com/static/images/automation-builder-logo-light.svg',
|
||||||
favIconUrl: 'https://cdn.activepieces.com/brand/favicon.ico',
|
favIconUrl: 'https://cdn.activepieces.com/brand/favicon.ico',
|
||||||
logoIconUrl: 'https://cdn.activepieces.com/brand/logo.svg',
|
logoIconUrl: 'https://cdn.activepieces.com/brand/logo.svg',
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -141,16 +141,20 @@ main() {
|
|||||||
echo "Custom Pieces Registration"
|
echo "Custom Pieces Registration"
|
||||||
echo "============================================"
|
echo "============================================"
|
||||||
|
|
||||||
# Configure registry first (needed for both Verdaccio and fallback to npm)
|
# Check if Verdaccio is configured and available
|
||||||
if wait_for_verdaccio; then
|
if [ -n "$VERDACCIO_URL" ] && [ "$VERDACCIO_URL" != "none" ]; then
|
||||||
configure_registry
|
if wait_for_verdaccio; then
|
||||||
|
configure_registry
|
||||||
|
|
||||||
# Publish each custom piece
|
# Publish each custom piece
|
||||||
for piece in $CUSTOM_PIECES; do
|
for piece in $CUSTOM_PIECES; do
|
||||||
publish_piece "$piece" || true
|
publish_piece "$piece" || true
|
||||||
done
|
done
|
||||||
|
else
|
||||||
|
echo "Skipping Verdaccio publishing - pieces are pre-built in image"
|
||||||
|
fi
|
||||||
else
|
else
|
||||||
echo "Skipping Verdaccio publishing - will use npm registry"
|
echo "Verdaccio not configured - using pre-built pieces from image"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Insert metadata into database
|
# Insert metadata into database
|
||||||
|
|||||||
36
frontend/src/api/activepieces.ts
Normal file
36
frontend/src/api/activepieces.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import api from './client';
|
||||||
|
|
||||||
|
export interface DefaultFlow {
|
||||||
|
flow_type: string;
|
||||||
|
display_name: string;
|
||||||
|
activepieces_flow_id: string | null;
|
||||||
|
is_modified: boolean;
|
||||||
|
is_enabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RestoreFlowResponse {
|
||||||
|
success: boolean;
|
||||||
|
flow_type: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RestoreAllResponse {
|
||||||
|
success: boolean;
|
||||||
|
restored: string[];
|
||||||
|
failed: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getDefaultFlows = async (): Promise<DefaultFlow[]> => {
|
||||||
|
const response = await api.get('/activepieces/default-flows/');
|
||||||
|
return response.data.flows;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const restoreFlow = async (flowType: string): Promise<RestoreFlowResponse> => {
|
||||||
|
const response = await api.post(`/activepieces/default-flows/${flowType}/restore/`);
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const restoreAllFlows = async (): Promise<RestoreAllResponse> => {
|
||||||
|
const response = await api.post('/activepieces/default-flows/restore-all/');
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
35
frontend/src/hooks/useActivepieces.ts
Normal file
35
frontend/src/hooks/useActivepieces.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import * as activepiecesApi from '../api/activepieces';
|
||||||
|
|
||||||
|
export const activepiecesKeys = {
|
||||||
|
all: ['activepieces'] as const,
|
||||||
|
defaultFlows: () => [...activepiecesKeys.all, 'defaultFlows'] as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useDefaultFlows = () => {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: activepiecesKeys.defaultFlows(),
|
||||||
|
queryFn: activepiecesApi.getDefaultFlows,
|
||||||
|
staleTime: 30 * 1000,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useRestoreFlow = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: activepiecesApi.restoreFlow,
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: activepiecesKeys.defaultFlows() });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useRestoreAllFlows = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: activepiecesApi.restoreAllFlows,
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: activepiecesKeys.defaultFlows() });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -1,10 +1,13 @@
|
|||||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { Bot, RefreshCw, AlertTriangle, Loader2, ExternalLink, Sparkles } from 'lucide-react';
|
import { Bot, RefreshCw, AlertTriangle, Loader2, ExternalLink, Sparkles, RotateCcw, ChevronDown } from 'lucide-react';
|
||||||
import api from '../api/client';
|
import api from '../api/client';
|
||||||
import { usePlanFeatures } from '../hooks/usePlanFeatures';
|
import { usePlanFeatures } from '../hooks/usePlanFeatures';
|
||||||
import { LockedSection } from '../components/UpgradePrompt';
|
import { UpgradePrompt } from '../components/UpgradePrompt';
|
||||||
|
import { useDarkMode } from '../hooks/useDarkMode';
|
||||||
|
import { useDefaultFlows, useRestoreFlow, useRestoreAllFlows } from '../hooks/useActivepieces';
|
||||||
|
import ConfirmationModal from '../components/ConfirmationModal';
|
||||||
|
|
||||||
interface ActivepiecesEmbedData {
|
interface ActivepiecesEmbedData {
|
||||||
token: string;
|
token: string;
|
||||||
@@ -40,11 +43,22 @@ const ActivepiecesVendorEventName = {
|
|||||||
*/
|
*/
|
||||||
export default function Automations() {
|
export default function Automations() {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const { features, loading: featuresLoading } = usePlanFeatures();
|
const { permissions, isLoading: featuresLoading, canUse } = usePlanFeatures();
|
||||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||||
const [iframeReady, setIframeReady] = useState(false);
|
const [iframeReady, setIframeReady] = useState(false);
|
||||||
const [authenticated, setAuthenticated] = useState(false);
|
const [authenticated, setAuthenticated] = useState(false);
|
||||||
const initSentRef = useRef(false);
|
const initSentRef = useRef(false);
|
||||||
|
const [refreshKey, setRefreshKey] = useState(0);
|
||||||
|
|
||||||
|
// Dark mode support
|
||||||
|
const isDark = useDarkMode();
|
||||||
|
|
||||||
|
// Restore default flows
|
||||||
|
const { data: defaultFlows } = useDefaultFlows();
|
||||||
|
const restoreFlow = useRestoreFlow();
|
||||||
|
const restoreAll = useRestoreAllFlows();
|
||||||
|
const [showFlowsMenu, setShowFlowsMenu] = useState(false);
|
||||||
|
const [confirmRestore, setConfirmRestore] = useState<{ type: 'all' | string; name: string } | null>(null);
|
||||||
|
|
||||||
// Fetch embed token for Activepieces
|
// Fetch embed token for Activepieces
|
||||||
const {
|
const {
|
||||||
@@ -98,12 +112,13 @@ export default function Automations() {
|
|||||||
hidePageHeader: false,
|
hidePageHeader: false,
|
||||||
locale: i18n.language || 'en',
|
locale: i18n.language || 'en',
|
||||||
initialRoute: '/flows', // Start on flows page to show sidebar
|
initialRoute: '/flows', // Start on flows page to show sidebar
|
||||||
|
mode: isDark ? 'dark' : 'light',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
iframeRef.current.contentWindow.postMessage(initMessage, '*');
|
iframeRef.current.contentWindow.postMessage(initMessage, '*');
|
||||||
initSentRef.current = true;
|
initSentRef.current = true;
|
||||||
}, [embedData?.token, i18n.language]);
|
}, [embedData?.token, i18n.language, isDark]);
|
||||||
|
|
||||||
// Listen for messages from Activepieces iframe
|
// Listen for messages from Activepieces iframe
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -151,8 +166,27 @@ export default function Automations() {
|
|||||||
}
|
}
|
||||||
}, [embedData?.token]);
|
}, [embedData?.token]);
|
||||||
|
|
||||||
|
// Reset state when theme changes to force iframe reload
|
||||||
|
useEffect(() => {
|
||||||
|
// Reset state so the iframe reinitializes with new theme
|
||||||
|
setIframeReady(false);
|
||||||
|
setAuthenticated(false);
|
||||||
|
initSentRef.current = false;
|
||||||
|
}, [isDark]);
|
||||||
|
|
||||||
|
// Close flows menu when clicking outside
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (showFlowsMenu && !(event.target as Element).closest('.flows-menu-container')) {
|
||||||
|
setShowFlowsMenu(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
}, [showFlowsMenu]);
|
||||||
|
|
||||||
// Check feature access
|
// Check feature access
|
||||||
const canAccessAutomations = features?.can_access_automations ?? true;
|
const canAccessAutomations = canUse('automations');
|
||||||
|
|
||||||
// Loading state
|
// Loading state
|
||||||
if (isLoading || featuresLoading) {
|
if (isLoading || featuresLoading) {
|
||||||
@@ -172,14 +206,7 @@ export default function Automations() {
|
|||||||
if (!canAccessAutomations) {
|
if (!canAccessAutomations) {
|
||||||
return (
|
return (
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
<LockedSection
|
<UpgradePrompt feature="automations" variant="banner" />
|
||||||
title={t('automations.locked.title', 'Automations')}
|
|
||||||
description={t(
|
|
||||||
'automations.locked.description',
|
|
||||||
'Upgrade your plan to access powerful workflow automation with AI-powered flow creation.'
|
|
||||||
)}
|
|
||||||
featureName="automations"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -212,8 +239,9 @@ export default function Automations() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Build iframe URL - use /embed route for SDK communication
|
// Build iframe URL - use /embed route for SDK communication
|
||||||
|
// Include theme parameter for dark mode support
|
||||||
const iframeSrc = embedData?.embedUrl
|
const iframeSrc = embedData?.embedUrl
|
||||||
? `${embedData.embedUrl}/embed`
|
? `${embedData.embedUrl}/embed?theme=${isDark ? 'dark' : 'light'}`
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
// Show loading until authenticated
|
// Show loading until authenticated
|
||||||
@@ -221,6 +249,47 @@ export default function Automations() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col">
|
<div className="h-full flex flex-col">
|
||||||
|
{/* Confirmation Modal */}
|
||||||
|
<ConfirmationModal
|
||||||
|
isOpen={!!confirmRestore}
|
||||||
|
onClose={() => setConfirmRestore(null)}
|
||||||
|
onConfirm={() => {
|
||||||
|
const refreshIframe = () => {
|
||||||
|
// Reset iframe state and increment key to force remount
|
||||||
|
initSentRef.current = false;
|
||||||
|
setAuthenticated(false);
|
||||||
|
setIframeReady(false);
|
||||||
|
setRefreshKey((k) => k + 1);
|
||||||
|
refetch();
|
||||||
|
setConfirmRestore(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (confirmRestore?.type === 'all') {
|
||||||
|
restoreAll.mutate(undefined, {
|
||||||
|
onSuccess: refreshIframe,
|
||||||
|
});
|
||||||
|
} else if (confirmRestore) {
|
||||||
|
restoreFlow.mutate(confirmRestore.type, {
|
||||||
|
onSuccess: refreshIframe,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
title={t('automations.restore.title', 'Restore Default Flow')}
|
||||||
|
message={
|
||||||
|
confirmRestore?.type === 'all'
|
||||||
|
? t(
|
||||||
|
'automations.restore.allMessage',
|
||||||
|
'This will restore all default automation flows. Any customizations will be overwritten.'
|
||||||
|
)
|
||||||
|
: t('automations.restore.singleMessage', 'Restore "{{name}}" to its default configuration?', {
|
||||||
|
name: confirmRestore?.name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
confirmText={t('automations.restore.confirm', 'Restore')}
|
||||||
|
variant="warning"
|
||||||
|
isLoading={restoreFlow.isPending || restoreAll.isPending}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 px-6 py-4">
|
<div className="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 px-6 py-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@@ -228,17 +297,9 @@ export default function Automations() {
|
|||||||
<div className="p-2 bg-primary-100 dark:bg-primary-900/30 rounded-lg">
|
<div className="p-2 bg-primary-100 dark:bg-primary-900/30 rounded-lg">
|
||||||
<Bot className="h-6 w-6 text-primary-600 dark:text-primary-400" />
|
<Bot className="h-6 w-6 text-primary-600 dark:text-primary-400" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<h1 className="text-xl font-semibold text-gray-900 dark:text-white">
|
||||||
<h1 className="text-xl font-semibold text-gray-900 dark:text-white">
|
{t('automations.title', 'Automations')}
|
||||||
{t('automations.title', 'Automations')}
|
</h1>
|
||||||
</h1>
|
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
|
||||||
{t(
|
|
||||||
'automations.subtitle',
|
|
||||||
'Build powerful workflows to automate your business'
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center space-x-3">
|
<div className="flex items-center space-x-3">
|
||||||
@@ -250,12 +311,65 @@ export default function Automations() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Restore Default Flows dropdown */}
|
||||||
|
<div className="relative flows-menu-container">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowFlowsMenu(!showFlowsMenu)}
|
||||||
|
className="flex items-center gap-2 px-3 py-2 text-sm bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<RotateCcw className="h-4 w-4" />
|
||||||
|
<span className="hidden sm:inline">{t('automations.restoreDefaults', 'Restore Defaults')}</span>
|
||||||
|
<ChevronDown className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showFlowsMenu && (
|
||||||
|
<div className="absolute right-0 mt-2 w-72 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 z-50">
|
||||||
|
<div className="p-2 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setConfirmRestore({ type: 'all', name: 'All Flows' });
|
||||||
|
setShowFlowsMenu(false);
|
||||||
|
}}
|
||||||
|
className="w-full px-3 py-2 text-left text-sm font-medium text-primary-600 dark:text-primary-400 hover:bg-primary-50 dark:hover:bg-primary-900/20 rounded transition-colors"
|
||||||
|
>
|
||||||
|
{t('automations.restoreAll', 'Restore All Default Flows')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="max-h-64 overflow-y-auto p-2">
|
||||||
|
{defaultFlows?.map((flow) => (
|
||||||
|
<button
|
||||||
|
key={flow.flow_type}
|
||||||
|
onClick={() => {
|
||||||
|
setConfirmRestore({ type: flow.flow_type, name: flow.display_name });
|
||||||
|
setShowFlowsMenu(false);
|
||||||
|
}}
|
||||||
|
className="w-full px-3 py-2 text-left text-sm hover:bg-gray-100 dark:hover:bg-gray-700 rounded transition-colors flex justify-between items-center"
|
||||||
|
>
|
||||||
|
<span className="text-gray-700 dark:text-gray-300">{flow.display_name}</span>
|
||||||
|
{flow.is_modified && (
|
||||||
|
<span className="text-xs text-orange-500 dark:text-orange-400">
|
||||||
|
{t('automations.modified', 'Modified')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{(!defaultFlows || defaultFlows.length === 0) && (
|
||||||
|
<p className="px-3 py-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{t('automations.noDefaultFlows', 'No default flows available')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Refresh button */}
|
{/* Refresh button */}
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
initSentRef.current = false;
|
initSentRef.current = false;
|
||||||
setAuthenticated(false);
|
setAuthenticated(false);
|
||||||
setIframeReady(false);
|
setIframeReady(false);
|
||||||
|
setRefreshKey((k) => k + 1);
|
||||||
refetch();
|
refetch();
|
||||||
}}
|
}}
|
||||||
className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
|
className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
|
||||||
@@ -296,6 +410,7 @@ export default function Automations() {
|
|||||||
|
|
||||||
{iframeSrc && (
|
{iframeSrc && (
|
||||||
<iframe
|
<iframe
|
||||||
|
key={`activepieces-${isDark ? 'dark' : 'light'}-${refreshKey}`}
|
||||||
ref={iframeRef}
|
ref={iframeRef}
|
||||||
src={iframeSrc}
|
src={iframeSrc}
|
||||||
className="w-full h-full border-0"
|
className="w-full h-full border-0"
|
||||||
|
|||||||
@@ -44,5 +44,8 @@ AP_EMBEDDING_ENABLED=true
|
|||||||
# The build watcher may fail without nx, but the piece should still load from pre-built dist
|
# The build watcher may fail without nx, but the piece should still load from pre-built dist
|
||||||
AP_DEV_PIECES=smoothschedule,python-code,ruby-code
|
AP_DEV_PIECES=smoothschedule,python-code,ruby-code
|
||||||
|
|
||||||
|
# Verdaccio - set to 'none' to skip Verdaccio (pieces are pre-built in image)
|
||||||
|
VERDACCIO_URL=none
|
||||||
|
|
||||||
# Templates Source URL - fetch official Activepieces templates from cloud
|
# Templates Source URL - fetch official Activepieces templates from cloud
|
||||||
AP_TEMPLATES_SOURCE_URL=https://cloud.activepieces.com/api/v1/templates
|
AP_TEMPLATES_SOURCE_URL=https://cloud.activepieces.com/api/v1/templates
|
||||||
|
|||||||
@@ -199,7 +199,7 @@ def _provision_activepieces_connection(tenant_id):
|
|||||||
tenant = Tenant.objects.get(id=tenant_id)
|
tenant = Tenant.objects.get(id=tenant_id)
|
||||||
|
|
||||||
# Check if tenant has the automation feature (optional check)
|
# Check if tenant has the automation feature (optional check)
|
||||||
if hasattr(tenant, 'has_feature') and not tenant.has_feature('can_use_plugins'):
|
if hasattr(tenant, 'has_feature') and not tenant.has_feature('can_use_automations'):
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f"Tenant {tenant.schema_name} doesn't have automation feature, "
|
f"Tenant {tenant.schema_name} doesn't have automation feature, "
|
||||||
"skipping Activepieces connection"
|
"skipping Activepieces connection"
|
||||||
@@ -266,7 +266,7 @@ def _provision_default_flows_for_tenant(tenant_id):
|
|||||||
tenant = Tenant.objects.get(id=tenant_id)
|
tenant = Tenant.objects.get(id=tenant_id)
|
||||||
|
|
||||||
# Check if tenant has the automation feature
|
# Check if tenant has the automation feature
|
||||||
if hasattr(tenant, 'has_feature') and not tenant.has_feature('can_use_plugins'):
|
if hasattr(tenant, 'has_feature') and not tenant.has_feature('can_use_automations'):
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f"Tenant {tenant.schema_name} doesn't have automation feature, "
|
f"Tenant {tenant.schema_name} doesn't have automation feature, "
|
||||||
"skipping default flows"
|
"skipping default flows"
|
||||||
@@ -283,6 +283,7 @@ def _provision_default_flows_for_tenant(tenant_id):
|
|||||||
)
|
)
|
||||||
from smoothschedule.integrations.activepieces.default_flows import (
|
from smoothschedule.integrations.activepieces.default_flows import (
|
||||||
get_all_flow_definitions,
|
get_all_flow_definitions,
|
||||||
|
get_sample_data_for_flow,
|
||||||
FLOW_VERSION,
|
FLOW_VERSION,
|
||||||
)
|
)
|
||||||
from django_tenants.utils import schema_context
|
from django_tenants.utils import schema_context
|
||||||
@@ -332,6 +333,7 @@ def _provision_default_flows_for_tenant(tenant_id):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
# Create the flow in Activepieces
|
# Create the flow in Activepieces
|
||||||
|
# Put default flows in a "Defaults" folder for organization
|
||||||
created_flow = client.create_flow(
|
created_flow = client.create_flow(
|
||||||
project_id=project_id,
|
project_id=project_id,
|
||||||
token=session_token,
|
token=session_token,
|
||||||
@@ -339,6 +341,7 @@ def _provision_default_flows_for_tenant(tenant_id):
|
|||||||
"displayName": flow_def.get("displayName", flow_type),
|
"displayName": flow_def.get("displayName", flow_type),
|
||||||
"trigger": flow_def.get("trigger"),
|
"trigger": flow_def.get("trigger"),
|
||||||
},
|
},
|
||||||
|
folder_name="Defaults",
|
||||||
)
|
)
|
||||||
|
|
||||||
flow_id = created_flow.get("id")
|
flow_id = created_flow.get("id")
|
||||||
@@ -348,8 +351,30 @@ def _provision_default_flows_for_tenant(tenant_id):
|
|||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Enable the flow
|
# Save sample data for the trigger
|
||||||
client.update_flow_status(flow_id, session_token, enabled=True)
|
sample_data = get_sample_data_for_flow(flow_type)
|
||||||
|
if sample_data:
|
||||||
|
try:
|
||||||
|
client.save_sample_data(
|
||||||
|
flow_id=flow_id,
|
||||||
|
token=session_token,
|
||||||
|
step_name="trigger",
|
||||||
|
sample_data=sample_data,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
f"Failed to save sample data for flow {flow_type}: {e}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Publish the flow (locks version and enables)
|
||||||
|
try:
|
||||||
|
client.publish_flow(flow_id, session_token)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
f"Failed to publish flow {flow_type}, enabling instead: {e}"
|
||||||
|
)
|
||||||
|
# Fallback to just enabling
|
||||||
|
client.update_flow_status(flow_id, session_token, enabled=True)
|
||||||
|
|
||||||
# Store the flow record in Django
|
# Store the flow record in Django
|
||||||
TenantDefaultFlow.objects.create(
|
TenantDefaultFlow.objects.create(
|
||||||
|
|||||||
@@ -12,19 +12,133 @@ Flow structure follows Activepieces format:
|
|||||||
from typing import Dict, Any
|
from typing import Dict, Any
|
||||||
|
|
||||||
# Version for tracking upgrades
|
# Version for tracking upgrades
|
||||||
FLOW_VERSION = "1.0.0"
|
# 1.0.0 - Initial default flows
|
||||||
|
# 1.1.0 - Fixed context variable names to match email template tags
|
||||||
|
FLOW_VERSION = "1.1.0"
|
||||||
|
|
||||||
# Template types for email actions
|
# System email types for the send_email action
|
||||||
EMAIL_TEMPLATES = {
|
EMAIL_TYPES = {
|
||||||
"appointment_confirmation": "APPOINTMENT_CONFIRMATION",
|
"appointment_confirmation": "appointment_confirmation",
|
||||||
"appointment_reminder": "APPOINTMENT_REMINDER",
|
"appointment_reminder": "appointment_reminder",
|
||||||
"thank_you": "THANK_YOU",
|
"thank_you": "payment_receipt", # Use payment_receipt template for thank you
|
||||||
"payment_receipt": "PAYMENT_RECEIPT",
|
"payment_receipt": "payment_receipt",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Sample data for each flow type - used to pre-populate trigger outputs
|
||||||
|
SAMPLE_DATA = {
|
||||||
|
"event_created": {
|
||||||
|
"id": 12345,
|
||||||
|
"title": "Consultation with John Doe",
|
||||||
|
"start_time": "2024-12-15T14:00:00Z",
|
||||||
|
"end_time": "2024-12-15T15:00:00Z",
|
||||||
|
"status": "SCHEDULED",
|
||||||
|
"resource_name": "Dr. Smith",
|
||||||
|
"location": "Main Office",
|
||||||
|
"service": {
|
||||||
|
"id": 1,
|
||||||
|
"name": "Consultation",
|
||||||
|
"price": "200.00",
|
||||||
|
},
|
||||||
|
"customer": {
|
||||||
|
"id": 50,
|
||||||
|
"first_name": "John",
|
||||||
|
"last_name": "Doe",
|
||||||
|
"email": "john.doe@example.com",
|
||||||
|
"phone": "+1-555-0100",
|
||||||
|
},
|
||||||
|
"event": {
|
||||||
|
"id": 12345,
|
||||||
|
"title": "Consultation with John Doe",
|
||||||
|
"start_time": "2024-12-15T14:00:00Z",
|
||||||
|
"end_time": "2024-12-15T15:00:00Z",
|
||||||
|
"status": "SCHEDULED",
|
||||||
|
"resource_name": "Dr. Smith",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"upcoming_events": {
|
||||||
|
"id": 12345,
|
||||||
|
"title": "Consultation with John Doe",
|
||||||
|
"start_time": "2024-12-15T14:00:00Z",
|
||||||
|
"end_time": "2024-12-15T15:00:00Z",
|
||||||
|
"status": "SCHEDULED",
|
||||||
|
"hours_until_start": 23.5,
|
||||||
|
"reminder_hours_before": 24,
|
||||||
|
"should_send_reminder": True,
|
||||||
|
"resource_name": "Dr. Smith",
|
||||||
|
"location": "Main Office",
|
||||||
|
"location_address": "123 Main St, City, ST 12345",
|
||||||
|
"service": {
|
||||||
|
"id": 1,
|
||||||
|
"name": "Consultation",
|
||||||
|
"price": "200.00",
|
||||||
|
},
|
||||||
|
"customer": {
|
||||||
|
"id": 50,
|
||||||
|
"first_name": "John",
|
||||||
|
"last_name": "Doe",
|
||||||
|
"email": "john.doe@example.com",
|
||||||
|
"phone": "+1-555-0100",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"payment_received": {
|
||||||
|
"id": 12345,
|
||||||
|
"payment_intent_id": "pi_3QDEr5GvIfP3a7s90bcd1234",
|
||||||
|
"amount": "50.00",
|
||||||
|
"currency": "usd",
|
||||||
|
"type": "deposit",
|
||||||
|
"status": "SUCCEEDED",
|
||||||
|
"created_at": "2024-12-01T10:00:00Z",
|
||||||
|
"completed_at": "2024-12-01T10:00:05Z",
|
||||||
|
"event": {
|
||||||
|
"id": 100,
|
||||||
|
"title": "Consultation with John Doe",
|
||||||
|
"start_time": "2024-12-15T14:00:00Z",
|
||||||
|
"end_time": "2024-12-15T15:00:00Z",
|
||||||
|
"status": "SCHEDULED",
|
||||||
|
"deposit_amount": "50.00",
|
||||||
|
"final_price": "200.00",
|
||||||
|
"remaining_balance": "150.00",
|
||||||
|
},
|
||||||
|
"service": {
|
||||||
|
"id": 1,
|
||||||
|
"name": "Consultation",
|
||||||
|
"price": "200.00",
|
||||||
|
},
|
||||||
|
"customer": {
|
||||||
|
"id": 50,
|
||||||
|
"first_name": "John",
|
||||||
|
"last_name": "Doe",
|
||||||
|
"email": "john.doe@example.com",
|
||||||
|
"phone": "+1-555-0100",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
# Map flow types to their trigger names and sample data
|
||||||
|
FLOW_SAMPLE_DATA = {
|
||||||
|
"appointment_confirmation": SAMPLE_DATA["event_created"],
|
||||||
|
"appointment_reminder": SAMPLE_DATA["upcoming_events"],
|
||||||
|
"thank_you": SAMPLE_DATA["payment_received"],
|
||||||
|
"payment_deposit": SAMPLE_DATA["payment_received"],
|
||||||
|
"payment_final": SAMPLE_DATA["payment_received"],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_sample_data_for_flow(flow_type: str) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Get the sample data for a given flow type.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
flow_type: One of the FlowType choices from TenantDefaultFlow model
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Sample data dict for the flow's trigger
|
||||||
|
"""
|
||||||
|
return FLOW_SAMPLE_DATA.get(flow_type, {})
|
||||||
|
|
||||||
|
|
||||||
def _create_send_email_action(
|
def _create_send_email_action(
|
||||||
template_type: str,
|
email_type: str,
|
||||||
step_name: str = "send_email",
|
step_name: str = "send_email",
|
||||||
next_action: Dict[str, Any] = None,
|
next_action: Dict[str, Any] = None,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
@@ -32,7 +146,7 @@ def _create_send_email_action(
|
|||||||
Create a send_email action step.
|
Create a send_email action step.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
template_type: The email template to use (e.g., "APPOINTMENT_CONFIRMATION")
|
email_type: The system email template to use (e.g., "appointment_confirmation")
|
||||||
step_name: Unique step name
|
step_name: Unique step name
|
||||||
next_action: Optional next action in the chain
|
next_action: Optional next action in the chain
|
||||||
|
|
||||||
@@ -52,16 +166,24 @@ def _create_send_email_action(
|
|||||||
"input": {
|
"input": {
|
||||||
# These use Activepieces interpolation syntax
|
# These use Activepieces interpolation syntax
|
||||||
# {{trigger.customer.email}} references the trigger output
|
# {{trigger.customer.email}} references the trigger output
|
||||||
"recipientEmail": "{{trigger.customer.email}}",
|
"to_email": "{{trigger.customer.email}}",
|
||||||
"templateType": template_type,
|
"template_type": "system",
|
||||||
|
"email_type": email_type,
|
||||||
"context": {
|
"context": {
|
||||||
# Map trigger data to template context
|
# Map trigger data to template context
|
||||||
"customer_name": "{{trigger.customer.first_name}}",
|
# Use template tag names that match email_tags.py definitions
|
||||||
|
"customer_first_name": "{{trigger.customer.first_name}}",
|
||||||
|
"customer_last_name": "{{trigger.customer.last_name}}",
|
||||||
|
"customer_name": "{{trigger.customer.first_name}} {{trigger.customer.last_name}}",
|
||||||
"customer_email": "{{trigger.customer.email}}",
|
"customer_email": "{{trigger.customer.email}}",
|
||||||
"event_title": "{{trigger.event.title}}",
|
"customer_phone": "{{trigger.customer.phone}}",
|
||||||
"event_date": "{{trigger.event.start_time}}",
|
|
||||||
"service_name": "{{trigger.service.name}}",
|
"service_name": "{{trigger.service.name}}",
|
||||||
"amount": "{{trigger.amount}}",
|
# Appointment fields
|
||||||
|
"appointment_date": "{{trigger.event.start_time}}",
|
||||||
|
"appointment_time": "{{trigger.event.start_time}}",
|
||||||
|
"appointment_datetime": "{{trigger.event.start_time}}",
|
||||||
|
"staff_name": "{{trigger.event.resource_name}}",
|
||||||
|
"location_name": "{{trigger.event.location}}",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"inputUiInfo": {
|
"inputUiInfo": {
|
||||||
@@ -102,7 +224,7 @@ def get_appointment_confirmation_flow() -> Dict[str, Any]:
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
"nextAction": _create_send_email_action(
|
"nextAction": _create_send_email_action(
|
||||||
template_type=EMAIL_TEMPLATES["appointment_confirmation"],
|
email_type=EMAIL_TYPES["appointment_confirmation"],
|
||||||
step_name="send_confirmation_email",
|
step_name="send_confirmation_email",
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -149,15 +271,22 @@ def get_appointment_reminder_flow() -> Dict[str, Any]:
|
|||||||
"pieceType": "CUSTOM",
|
"pieceType": "CUSTOM",
|
||||||
"actionName": "send_email",
|
"actionName": "send_email",
|
||||||
"input": {
|
"input": {
|
||||||
"recipientEmail": "{{trigger.customer.email}}",
|
"to_email": "{{trigger.customer.email}}",
|
||||||
"templateType": EMAIL_TEMPLATES["appointment_reminder"],
|
"template_type": "system",
|
||||||
|
"email_type": EMAIL_TYPES["appointment_reminder"],
|
||||||
"context": {
|
"context": {
|
||||||
"customer_name": "{{trigger.customer.first_name}}",
|
"customer_first_name": "{{trigger.customer.first_name}}",
|
||||||
|
"customer_last_name": "{{trigger.customer.last_name}}",
|
||||||
|
"customer_name": "{{trigger.customer.first_name}} {{trigger.customer.last_name}}",
|
||||||
"customer_email": "{{trigger.customer.email}}",
|
"customer_email": "{{trigger.customer.email}}",
|
||||||
"event_title": "{{trigger.title}}",
|
"customer_phone": "{{trigger.customer.phone}}",
|
||||||
"event_date": "{{trigger.start_time}}",
|
|
||||||
"service_name": "{{trigger.service.name}}",
|
"service_name": "{{trigger.service.name}}",
|
||||||
"hours_until": "{{trigger.hours_until_start}}",
|
"appointment_date": "{{trigger.start_time}}",
|
||||||
|
"appointment_time": "{{trigger.start_time}}",
|
||||||
|
"appointment_datetime": "{{trigger.start_time}}",
|
||||||
|
"staff_name": "{{trigger.resource_name}}",
|
||||||
|
"location_name": "{{trigger.location}}",
|
||||||
|
"location_address": "{{trigger.location_address}}",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"inputUiInfo": {
|
"inputUiInfo": {
|
||||||
@@ -208,14 +337,20 @@ def get_thank_you_flow() -> Dict[str, Any]:
|
|||||||
"pieceType": "CUSTOM",
|
"pieceType": "CUSTOM",
|
||||||
"actionName": "send_email",
|
"actionName": "send_email",
|
||||||
"input": {
|
"input": {
|
||||||
"recipientEmail": "{{trigger.customer.email}}",
|
"to_email": "{{trigger.customer.email}}",
|
||||||
"templateType": EMAIL_TEMPLATES["thank_you"],
|
"template_type": "system",
|
||||||
|
"email_type": EMAIL_TYPES["thank_you"],
|
||||||
"context": {
|
"context": {
|
||||||
"customer_name": "{{trigger.customer.first_name}}",
|
"customer_first_name": "{{trigger.customer.first_name}}",
|
||||||
|
"customer_last_name": "{{trigger.customer.last_name}}",
|
||||||
|
"customer_name": "{{trigger.customer.first_name}} {{trigger.customer.last_name}}",
|
||||||
"customer_email": "{{trigger.customer.email}}",
|
"customer_email": "{{trigger.customer.email}}",
|
||||||
"event_title": "{{trigger.event.title}}",
|
"customer_phone": "{{trigger.customer.phone}}",
|
||||||
"service_name": "{{trigger.service.name}}",
|
"service_name": "{{trigger.service.name}}",
|
||||||
"amount": "{{trigger.amount}}",
|
"amount_paid": "{{trigger.amount}}",
|
||||||
|
"invoice_number": "{{trigger.payment_intent_id}}",
|
||||||
|
"appointment_date": "{{trigger.event.start_time}}",
|
||||||
|
"appointment_datetime": "{{trigger.event.start_time}}",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"inputUiInfo": {
|
"inputUiInfo": {
|
||||||
@@ -266,18 +401,23 @@ def get_deposit_payment_flow() -> Dict[str, Any]:
|
|||||||
"pieceType": "CUSTOM",
|
"pieceType": "CUSTOM",
|
||||||
"actionName": "send_email",
|
"actionName": "send_email",
|
||||||
"input": {
|
"input": {
|
||||||
"recipientEmail": "{{trigger.customer.email}}",
|
"to_email": "{{trigger.customer.email}}",
|
||||||
"templateType": EMAIL_TEMPLATES["payment_receipt"],
|
"template_type": "system",
|
||||||
"subjectOverride": "Deposit Received - {{trigger.service.name}}",
|
"email_type": EMAIL_TYPES["payment_receipt"],
|
||||||
|
"subject_override": "Deposit Received - {{trigger.service.name}}",
|
||||||
"context": {
|
"context": {
|
||||||
"customer_name": "{{trigger.customer.first_name}}",
|
"customer_first_name": "{{trigger.customer.first_name}}",
|
||||||
|
"customer_last_name": "{{trigger.customer.last_name}}",
|
||||||
|
"customer_name": "{{trigger.customer.first_name}} {{trigger.customer.last_name}}",
|
||||||
"customer_email": "{{trigger.customer.email}}",
|
"customer_email": "{{trigger.customer.email}}",
|
||||||
"event_title": "{{trigger.event.title}}",
|
"customer_phone": "{{trigger.customer.phone}}",
|
||||||
"event_date": "{{trigger.event.start_time}}",
|
|
||||||
"service_name": "{{trigger.service.name}}",
|
"service_name": "{{trigger.service.name}}",
|
||||||
"amount": "{{trigger.amount}}",
|
"amount_paid": "{{trigger.amount}}",
|
||||||
"payment_type": "deposit",
|
"invoice_number": "{{trigger.payment_intent_id}}",
|
||||||
"remaining_balance": "{{trigger.event.remaining_balance}}",
|
"deposit_amount": "{{trigger.amount}}",
|
||||||
|
"total_paid": "{{trigger.amount}}",
|
||||||
|
"appointment_date": "{{trigger.event.start_time}}",
|
||||||
|
"appointment_datetime": "{{trigger.event.start_time}}",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"inputUiInfo": {
|
"inputUiInfo": {
|
||||||
@@ -328,15 +468,21 @@ def get_final_payment_flow() -> Dict[str, Any]:
|
|||||||
"pieceType": "CUSTOM",
|
"pieceType": "CUSTOM",
|
||||||
"actionName": "send_email",
|
"actionName": "send_email",
|
||||||
"input": {
|
"input": {
|
||||||
"recipientEmail": "{{trigger.customer.email}}",
|
"to_email": "{{trigger.customer.email}}",
|
||||||
"templateType": EMAIL_TEMPLATES["payment_receipt"],
|
"template_type": "system",
|
||||||
|
"email_type": EMAIL_TYPES["payment_receipt"],
|
||||||
"context": {
|
"context": {
|
||||||
"customer_name": "{{trigger.customer.first_name}}",
|
"customer_first_name": "{{trigger.customer.first_name}}",
|
||||||
|
"customer_last_name": "{{trigger.customer.last_name}}",
|
||||||
|
"customer_name": "{{trigger.customer.first_name}} {{trigger.customer.last_name}}",
|
||||||
"customer_email": "{{trigger.customer.email}}",
|
"customer_email": "{{trigger.customer.email}}",
|
||||||
"event_title": "{{trigger.event.title}}",
|
"customer_phone": "{{trigger.customer.phone}}",
|
||||||
"service_name": "{{trigger.service.name}}",
|
"service_name": "{{trigger.service.name}}",
|
||||||
"amount": "{{trigger.amount}}",
|
"amount_paid": "{{trigger.amount}}",
|
||||||
"payment_type": "final",
|
"invoice_number": "{{trigger.payment_intent_id}}",
|
||||||
|
"total_paid": "{{trigger.amount}}",
|
||||||
|
"appointment_date": "{{trigger.event.start_time}}",
|
||||||
|
"appointment_datetime": "{{trigger.event.start_time}}",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"inputUiInfo": {
|
"inputUiInfo": {
|
||||||
|
|||||||
@@ -316,7 +316,13 @@ class ActivepiecesClient:
|
|||||||
)
|
)
|
||||||
return result.get("data", [])
|
return result.get("data", [])
|
||||||
|
|
||||||
def create_flow(self, project_id: str, token: str, flow_data: dict) -> dict:
|
def create_flow(
|
||||||
|
self,
|
||||||
|
project_id: str,
|
||||||
|
token: str,
|
||||||
|
flow_data: dict,
|
||||||
|
folder_name: Optional[str] = None,
|
||||||
|
) -> dict:
|
||||||
"""
|
"""
|
||||||
Create a new flow in Activepieces.
|
Create a new flow in Activepieces.
|
||||||
|
|
||||||
@@ -324,18 +330,25 @@ class ActivepiecesClient:
|
|||||||
project_id: The Activepieces project ID
|
project_id: The Activepieces project ID
|
||||||
token: Session token for API calls
|
token: Session token for API calls
|
||||||
flow_data: Flow definition including displayName and trigger
|
flow_data: Flow definition including displayName and trigger
|
||||||
|
folder_name: Optional folder name to create/use for this flow
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Created flow object with id
|
Created flow object with id
|
||||||
"""
|
"""
|
||||||
# Create the flow shell
|
# Create the flow shell
|
||||||
|
create_data = {
|
||||||
|
"displayName": flow_data.get("displayName", "Untitled"),
|
||||||
|
"projectId": project_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Add folder if specified
|
||||||
|
if folder_name:
|
||||||
|
create_data["folderName"] = folder_name
|
||||||
|
|
||||||
result = self._request(
|
result = self._request(
|
||||||
"POST",
|
"POST",
|
||||||
"/api/v1/flows",
|
"/api/v1/flows",
|
||||||
data={
|
data=create_data,
|
||||||
"displayName": flow_data.get("displayName", "Untitled"),
|
|
||||||
"projectId": project_id,
|
|
||||||
},
|
|
||||||
token=token,
|
token=token,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -407,6 +420,69 @@ class ActivepiecesClient:
|
|||||||
token=token,
|
token=token,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def publish_flow(self, flow_id: str, token: str) -> dict:
|
||||||
|
"""
|
||||||
|
Publish a flow (lock and make it live).
|
||||||
|
|
||||||
|
Uses the LOCK_AND_PUBLISH operation which:
|
||||||
|
1. Locks the current version
|
||||||
|
2. Makes it the published (active) version
|
||||||
|
3. Enables the flow
|
||||||
|
|
||||||
|
Args:
|
||||||
|
flow_id: The Activepieces flow ID
|
||||||
|
token: Session token for API calls
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Updated flow object
|
||||||
|
"""
|
||||||
|
return self._request(
|
||||||
|
"POST",
|
||||||
|
f"/api/v1/flows/{flow_id}",
|
||||||
|
data={
|
||||||
|
"type": "LOCK_AND_PUBLISH",
|
||||||
|
"request": {},
|
||||||
|
},
|
||||||
|
token=token,
|
||||||
|
)
|
||||||
|
|
||||||
|
def save_sample_data(
|
||||||
|
self,
|
||||||
|
flow_id: str,
|
||||||
|
token: str,
|
||||||
|
step_name: str,
|
||||||
|
sample_data: dict,
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
Save sample data for a flow step (trigger or action).
|
||||||
|
|
||||||
|
This populates the sample data so users can see example
|
||||||
|
output and use it in subsequent steps.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
flow_id: The Activepieces flow ID
|
||||||
|
token: Session token for API calls
|
||||||
|
step_name: Name of the step (e.g., "trigger")
|
||||||
|
sample_data: Sample data to save
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Updated flow object
|
||||||
|
"""
|
||||||
|
return self._request(
|
||||||
|
"POST",
|
||||||
|
f"/api/v1/flows/{flow_id}",
|
||||||
|
data={
|
||||||
|
"type": "SAVE_SAMPLE_DATA",
|
||||||
|
"request": {
|
||||||
|
"stepName": step_name,
|
||||||
|
"payload": sample_data,
|
||||||
|
"type": "OUTPUT", # SampleDataFileType.OUTPUT
|
||||||
|
"dataType": "JSON", # SampleDataDataType.JSON
|
||||||
|
},
|
||||||
|
},
|
||||||
|
token=token,
|
||||||
|
)
|
||||||
|
|
||||||
def get_flow(self, flow_id: str, token: str) -> dict:
|
def get_flow(self, flow_id: str, token: str) -> dict:
|
||||||
"""
|
"""
|
||||||
Get a flow by ID.
|
Get a flow by ID.
|
||||||
@@ -470,6 +546,34 @@ class ActivepiecesClient:
|
|||||||
"""
|
"""
|
||||||
return self.embed_url
|
return self.embed_url
|
||||||
|
|
||||||
|
def get_session_token(self, tenant) -> tuple[str, str]:
|
||||||
|
"""
|
||||||
|
Get an Activepieces session token for API calls.
|
||||||
|
|
||||||
|
Unlike get_embed_session which returns a trust token for the frontend,
|
||||||
|
this returns the actual Activepieces session token needed for API calls.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tenant: The SmoothSchedule tenant
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (session_token, project_id)
|
||||||
|
"""
|
||||||
|
provisioning_token = self._generate_trust_token(tenant)
|
||||||
|
result = self._request(
|
||||||
|
"POST",
|
||||||
|
"/api/v1/authentication/django-trust",
|
||||||
|
data={"token": provisioning_token},
|
||||||
|
)
|
||||||
|
|
||||||
|
session_token = result.get("token")
|
||||||
|
project_id = result.get("projectId")
|
||||||
|
|
||||||
|
if not session_token:
|
||||||
|
raise ActivepiecesError("Failed to get Activepieces session token")
|
||||||
|
|
||||||
|
return session_token, project_id
|
||||||
|
|
||||||
|
|
||||||
def get_activepieces_client() -> ActivepiecesClient:
|
def get_activepieces_client() -> ActivepiecesClient:
|
||||||
"""Factory function to get an Activepieces client instance."""
|
"""Factory function to get an Activepieces client instance."""
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ from smoothschedule.identity.core.mixins import TenantRequiredAPIView
|
|||||||
|
|
||||||
from .models import TenantActivepiecesProject, TenantDefaultFlow
|
from .models import TenantActivepiecesProject, TenantDefaultFlow
|
||||||
from .services import ActivepiecesError, get_activepieces_client
|
from .services import ActivepiecesError, get_activepieces_client
|
||||||
from .default_flows import get_flow_definition, FLOW_VERSION
|
from .default_flows import get_flow_definition, get_sample_data_for_flow, FLOW_VERSION
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -291,9 +291,8 @@ class DefaultFlowRestoreView(TenantRequiredAPIView, APIView):
|
|||||||
client = get_activepieces_client()
|
client = get_activepieces_client()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Get session for this tenant
|
# Get session token for API calls (not trust token for frontend)
|
||||||
session_data = client.get_embed_session(tenant)
|
token, project_id = client.get_session_token(tenant)
|
||||||
token = session_data.get("token")
|
|
||||||
|
|
||||||
if not token:
|
if not token:
|
||||||
return self.error_response(
|
return self.error_response(
|
||||||
@@ -304,15 +303,61 @@ class DefaultFlowRestoreView(TenantRequiredAPIView, APIView):
|
|||||||
# Get the original flow definition
|
# Get the original flow definition
|
||||||
flow_def = get_flow_definition(flow_type)
|
flow_def = get_flow_definition(flow_type)
|
||||||
|
|
||||||
# Update the flow in Activepieces using import_flow
|
# Try to update the existing flow, or create a new one if it doesn't exist
|
||||||
client.import_flow(
|
try:
|
||||||
flow_id=default_flow.activepieces_flow_id,
|
client.import_flow(
|
||||||
token=token,
|
flow_id=default_flow.activepieces_flow_id,
|
||||||
display_name=flow_def.get("displayName", flow_type),
|
token=token,
|
||||||
trigger=flow_def.get("trigger"),
|
display_name=flow_def.get("displayName", flow_type),
|
||||||
)
|
trigger=flow_def.get("trigger"),
|
||||||
|
)
|
||||||
|
new_flow_id = default_flow.activepieces_flow_id
|
||||||
|
except ActivepiecesError as e:
|
||||||
|
# Flow doesn't exist in Activepieces (e.g., after container rebuild)
|
||||||
|
# Create a new one
|
||||||
|
if "404" in str(e):
|
||||||
|
logger.warning(
|
||||||
|
f"Flow {default_flow.activepieces_flow_id} not found in Activepieces, "
|
||||||
|
f"creating new flow for {flow_type}"
|
||||||
|
)
|
||||||
|
created_flow = client.create_flow(
|
||||||
|
project_id=project_id,
|
||||||
|
token=token,
|
||||||
|
flow_data={
|
||||||
|
"displayName": flow_def.get("displayName", flow_type),
|
||||||
|
"trigger": flow_def.get("trigger"),
|
||||||
|
},
|
||||||
|
folder_name="Defaults",
|
||||||
|
)
|
||||||
|
new_flow_id = created_flow.get("id")
|
||||||
|
if not new_flow_id:
|
||||||
|
raise ActivepiecesError("Failed to create replacement flow")
|
||||||
|
else:
|
||||||
|
raise
|
||||||
|
|
||||||
|
# Save sample data for the trigger
|
||||||
|
sample_data = get_sample_data_for_flow(flow_type)
|
||||||
|
if sample_data:
|
||||||
|
try:
|
||||||
|
client.save_sample_data(
|
||||||
|
flow_id=new_flow_id,
|
||||||
|
token=token,
|
||||||
|
step_name="trigger",
|
||||||
|
sample_data=sample_data,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to save sample data for flow {flow_type}: {e}")
|
||||||
|
|
||||||
|
# Publish the flow (locks version and enables)
|
||||||
|
try:
|
||||||
|
client.publish_flow(new_flow_id, token)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to publish flow {flow_type}, enabling instead: {e}")
|
||||||
|
# Fallback to just enabling
|
||||||
|
client.update_flow_status(new_flow_id, token, enabled=True)
|
||||||
|
|
||||||
# Update the Django record
|
# Update the Django record
|
||||||
|
default_flow.activepieces_flow_id = new_flow_id
|
||||||
default_flow.is_modified = False
|
default_flow.is_modified = False
|
||||||
default_flow.default_flow_json = flow_def
|
default_flow.default_flow_json = flow_def
|
||||||
default_flow.version = FLOW_VERSION
|
default_flow.version = FLOW_VERSION
|
||||||
@@ -361,9 +406,8 @@ class DefaultFlowsRestoreAllView(TenantRequiredAPIView, APIView):
|
|||||||
client = get_activepieces_client()
|
client = get_activepieces_client()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Get session for this tenant
|
# Get session token for API calls (not trust token for frontend)
|
||||||
session_data = client.get_embed_session(tenant)
|
token, project_id = client.get_session_token(tenant)
|
||||||
token = session_data.get("token")
|
|
||||||
|
|
||||||
if not token:
|
if not token:
|
||||||
return self.error_response(
|
return self.error_response(
|
||||||
@@ -382,15 +426,65 @@ class DefaultFlowsRestoreAllView(TenantRequiredAPIView, APIView):
|
|||||||
# Get the original flow definition
|
# Get the original flow definition
|
||||||
flow_def = get_flow_definition(default_flow.flow_type)
|
flow_def = get_flow_definition(default_flow.flow_type)
|
||||||
|
|
||||||
# Update the flow in Activepieces
|
# Try to update the existing flow, or create a new one if it doesn't exist
|
||||||
client.import_flow(
|
try:
|
||||||
flow_id=default_flow.activepieces_flow_id,
|
client.import_flow(
|
||||||
token=token,
|
flow_id=default_flow.activepieces_flow_id,
|
||||||
display_name=flow_def.get("displayName", default_flow.flow_type),
|
token=token,
|
||||||
trigger=flow_def.get("trigger"),
|
display_name=flow_def.get("displayName", default_flow.flow_type),
|
||||||
)
|
trigger=flow_def.get("trigger"),
|
||||||
|
)
|
||||||
|
new_flow_id = default_flow.activepieces_flow_id
|
||||||
|
except ActivepiecesError as e:
|
||||||
|
# Flow doesn't exist in Activepieces (e.g., after container rebuild)
|
||||||
|
# Create a new one
|
||||||
|
if "404" in str(e):
|
||||||
|
logger.warning(
|
||||||
|
f"Flow {default_flow.activepieces_flow_id} not found in Activepieces, "
|
||||||
|
f"creating new flow for {default_flow.flow_type}"
|
||||||
|
)
|
||||||
|
created_flow = client.create_flow(
|
||||||
|
project_id=project_id,
|
||||||
|
token=token,
|
||||||
|
flow_data={
|
||||||
|
"displayName": flow_def.get("displayName", default_flow.flow_type),
|
||||||
|
"trigger": flow_def.get("trigger"),
|
||||||
|
},
|
||||||
|
folder_name="Defaults",
|
||||||
|
)
|
||||||
|
new_flow_id = created_flow.get("id")
|
||||||
|
if not new_flow_id:
|
||||||
|
raise ActivepiecesError("Failed to create replacement flow")
|
||||||
|
else:
|
||||||
|
raise
|
||||||
|
|
||||||
|
# Save sample data for the trigger
|
||||||
|
sample_data = get_sample_data_for_flow(default_flow.flow_type)
|
||||||
|
if sample_data:
|
||||||
|
try:
|
||||||
|
client.save_sample_data(
|
||||||
|
flow_id=new_flow_id,
|
||||||
|
token=token,
|
||||||
|
step_name="trigger",
|
||||||
|
sample_data=sample_data,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
f"Failed to save sample data for flow {default_flow.flow_type}: {e}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Publish the flow (locks version and enables)
|
||||||
|
try:
|
||||||
|
client.publish_flow(new_flow_id, token)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
f"Failed to publish flow {default_flow.flow_type}, enabling instead: {e}"
|
||||||
|
)
|
||||||
|
# Fallback to just enabling
|
||||||
|
client.update_flow_status(new_flow_id, token, enabled=True)
|
||||||
|
|
||||||
# Update the Django record
|
# Update the Django record
|
||||||
|
default_flow.activepieces_flow_id = new_flow_id
|
||||||
default_flow.is_modified = False
|
default_flow.is_modified = False
|
||||||
default_flow.default_flow_json = flow_def
|
default_flow.default_flow_json = flow_def
|
||||||
default_flow.version = FLOW_VERSION
|
default_flow.version = FLOW_VERSION
|
||||||
|
|||||||
@@ -2454,6 +2454,20 @@ class SendEmailView(PublicAPIViewMixin, APIView):
|
|||||||
if 'business_website_url' not in context and hasattr(tenant, 'website'):
|
if 'business_website_url' not in context and hasattr(tenant, 'website'):
|
||||||
context['business_website_url'] = tenant.website or ''
|
context['business_website_url'] = tenant.website or ''
|
||||||
|
|
||||||
|
# Business address - try to construct from address fields if available
|
||||||
|
if 'business_address' not in context:
|
||||||
|
address_parts = []
|
||||||
|
if hasattr(tenant, 'address') and tenant.address:
|
||||||
|
address_parts.append(tenant.address)
|
||||||
|
if hasattr(tenant, 'city') and tenant.city:
|
||||||
|
city_state = tenant.city
|
||||||
|
if hasattr(tenant, 'state') and tenant.state:
|
||||||
|
city_state = f"{tenant.city}, {tenant.state}"
|
||||||
|
if hasattr(tenant, 'zip_code') and tenant.zip_code:
|
||||||
|
city_state = f"{city_state} {tenant.zip_code}"
|
||||||
|
address_parts.append(city_state)
|
||||||
|
context['business_address'] = ', '.join(address_parts) if address_parts else ''
|
||||||
|
|
||||||
# Add current date/year
|
# Add current date/year
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
now = timezone.now()
|
now = timezone.now()
|
||||||
|
|||||||
Reference in New Issue
Block a user