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 { listEmailTemplatesAction } from './lib/actions/list-email-templates';
|
||||
import { eventCreatedTrigger, eventUpdatedTrigger, eventCancelledTrigger, eventStatusChangedTrigger, paymentReceivedTrigger, upcomingEventsTrigger } from './lib/triggers';
|
||||
import { API_URL } from './lib/common';
|
||||
|
||||
/**
|
||||
* SmoothSchedule Authentication
|
||||
|
||||
@@ -38,6 +38,39 @@ interface PaymentData {
|
||||
} | 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({
|
||||
auth: smoothScheduleAuth,
|
||||
name: 'payment_received',
|
||||
@@ -78,15 +111,26 @@ export const paymentReceivedTrigger = createTrigger({
|
||||
queryParams['type'] = paymentType;
|
||||
}
|
||||
|
||||
const payments = await makeRequest<PaymentData[]>(
|
||||
auth,
|
||||
HttpMethod.GET,
|
||||
'/payments/',
|
||||
undefined,
|
||||
queryParams
|
||||
);
|
||||
try {
|
||||
const payments = await makeRequest<PaymentData[]>(
|
||||
auth,
|
||||
HttpMethod.GET,
|
||||
'/payments/',
|
||||
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) {
|
||||
const auth = context.auth as SmoothScheduleAuth;
|
||||
@@ -121,36 +165,5 @@ export const paymentReceivedTrigger = createTrigger({
|
||||
|
||||
return payments;
|
||||
},
|
||||
sampleData: {
|
||||
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',
|
||||
},
|
||||
},
|
||||
sampleData: SAMPLE_PAYMENT_DATA,
|
||||
});
|
||||
|
||||
@@ -1,15 +1,64 @@
|
||||
import { t } from 'i18next';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { flagsHooks } from '@/hooks/flags-hooks';
|
||||
import { useTheme } from '@/components/theme-provider';
|
||||
|
||||
const FullLogo = () => {
|
||||
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 (
|
||||
<div className="h-[60px]">
|
||||
<img
|
||||
className="h-full"
|
||||
src={branding.logos.fullLogoUrl}
|
||||
src={logoUrl}
|
||||
alt={t('logo')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -65,8 +65,8 @@ export function generateTheme({
|
||||
|
||||
export const defaultTheme = generateTheme({
|
||||
primaryColor: '#6e41e2',
|
||||
websiteName: 'Activepieces',
|
||||
fullLogoUrl: 'https://cdn.activepieces.com/brand/full-logo.png',
|
||||
websiteName: 'Automation Builder',
|
||||
fullLogoUrl: 'https://smoothschedule.nyc3.digitaloceanspaces.com/static/images/automation-builder-logo-light.svg',
|
||||
favIconUrl: 'https://cdn.activepieces.com/brand/favicon.ico',
|
||||
logoIconUrl: 'https://cdn.activepieces.com/brand/logo.svg',
|
||||
})
|
||||
|
||||
@@ -141,16 +141,20 @@ main() {
|
||||
echo "Custom Pieces Registration"
|
||||
echo "============================================"
|
||||
|
||||
# Configure registry first (needed for both Verdaccio and fallback to npm)
|
||||
if wait_for_verdaccio; then
|
||||
configure_registry
|
||||
# Check if Verdaccio is configured and available
|
||||
if [ -n "$VERDACCIO_URL" ] && [ "$VERDACCIO_URL" != "none" ]; then
|
||||
if wait_for_verdaccio; then
|
||||
configure_registry
|
||||
|
||||
# Publish each custom piece
|
||||
for piece in $CUSTOM_PIECES; do
|
||||
publish_piece "$piece" || true
|
||||
done
|
||||
# Publish each custom piece
|
||||
for piece in $CUSTOM_PIECES; do
|
||||
publish_piece "$piece" || true
|
||||
done
|
||||
else
|
||||
echo "Skipping Verdaccio publishing - pieces are pre-built in image"
|
||||
fi
|
||||
else
|
||||
echo "Skipping Verdaccio publishing - will use npm registry"
|
||||
echo "Verdaccio not configured - using pre-built pieces from image"
|
||||
fi
|
||||
|
||||
# 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 { useTranslation } from 'react-i18next';
|
||||
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 { 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 {
|
||||
token: string;
|
||||
@@ -40,11 +43,22 @@ const ActivepiecesVendorEventName = {
|
||||
*/
|
||||
export default function Automations() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { features, loading: featuresLoading } = usePlanFeatures();
|
||||
const { permissions, isLoading: featuresLoading, canUse } = usePlanFeatures();
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
const [iframeReady, setIframeReady] = useState(false);
|
||||
const [authenticated, setAuthenticated] = useState(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
|
||||
const {
|
||||
@@ -98,12 +112,13 @@ export default function Automations() {
|
||||
hidePageHeader: false,
|
||||
locale: i18n.language || 'en',
|
||||
initialRoute: '/flows', // Start on flows page to show sidebar
|
||||
mode: isDark ? 'dark' : 'light',
|
||||
},
|
||||
};
|
||||
|
||||
iframeRef.current.contentWindow.postMessage(initMessage, '*');
|
||||
initSentRef.current = true;
|
||||
}, [embedData?.token, i18n.language]);
|
||||
}, [embedData?.token, i18n.language, isDark]);
|
||||
|
||||
// Listen for messages from Activepieces iframe
|
||||
useEffect(() => {
|
||||
@@ -151,8 +166,27 @@ export default function Automations() {
|
||||
}
|
||||
}, [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
|
||||
const canAccessAutomations = features?.can_access_automations ?? true;
|
||||
const canAccessAutomations = canUse('automations');
|
||||
|
||||
// Loading state
|
||||
if (isLoading || featuresLoading) {
|
||||
@@ -172,14 +206,7 @@ export default function Automations() {
|
||||
if (!canAccessAutomations) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<LockedSection
|
||||
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"
|
||||
/>
|
||||
<UpgradePrompt feature="automations" variant="banner" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -212,8 +239,9 @@ export default function Automations() {
|
||||
}
|
||||
|
||||
// Build iframe URL - use /embed route for SDK communication
|
||||
// Include theme parameter for dark mode support
|
||||
const iframeSrc = embedData?.embedUrl
|
||||
? `${embedData.embedUrl}/embed`
|
||||
? `${embedData.embedUrl}/embed?theme=${isDark ? 'dark' : 'light'}`
|
||||
: '';
|
||||
|
||||
// Show loading until authenticated
|
||||
@@ -221,6 +249,47 @@ export default function Automations() {
|
||||
|
||||
return (
|
||||
<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 */}
|
||||
<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">
|
||||
@@ -228,17 +297,9 @@ export default function Automations() {
|
||||
<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" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-gray-900 dark:text-white">
|
||||
{t('automations.title', 'Automations')}
|
||||
</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{t(
|
||||
'automations.subtitle',
|
||||
'Build powerful workflows to automate your business'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<h1 className="text-xl font-semibold text-gray-900 dark:text-white">
|
||||
{t('automations.title', 'Automations')}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-3">
|
||||
@@ -250,12 +311,65 @@ export default function Automations() {
|
||||
</span>
|
||||
</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 */}
|
||||
<button
|
||||
onClick={() => {
|
||||
initSentRef.current = false;
|
||||
setAuthenticated(false);
|
||||
setIframeReady(false);
|
||||
setRefreshKey((k) => k + 1);
|
||||
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"
|
||||
@@ -296,6 +410,7 @@ export default function Automations() {
|
||||
|
||||
{iframeSrc && (
|
||||
<iframe
|
||||
key={`activepieces-${isDark ? 'dark' : 'light'}-${refreshKey}`}
|
||||
ref={iframeRef}
|
||||
src={iframeSrc}
|
||||
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
|
||||
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
|
||||
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)
|
||||
|
||||
# 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(
|
||||
f"Tenant {tenant.schema_name} doesn't have automation feature, "
|
||||
"skipping Activepieces connection"
|
||||
@@ -266,7 +266,7 @@ def _provision_default_flows_for_tenant(tenant_id):
|
||||
tenant = Tenant.objects.get(id=tenant_id)
|
||||
|
||||
# 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(
|
||||
f"Tenant {tenant.schema_name} doesn't have automation feature, "
|
||||
"skipping default flows"
|
||||
@@ -283,6 +283,7 @@ def _provision_default_flows_for_tenant(tenant_id):
|
||||
)
|
||||
from smoothschedule.integrations.activepieces.default_flows import (
|
||||
get_all_flow_definitions,
|
||||
get_sample_data_for_flow,
|
||||
FLOW_VERSION,
|
||||
)
|
||||
from django_tenants.utils import schema_context
|
||||
@@ -332,6 +333,7 @@ def _provision_default_flows_for_tenant(tenant_id):
|
||||
|
||||
try:
|
||||
# Create the flow in Activepieces
|
||||
# Put default flows in a "Defaults" folder for organization
|
||||
created_flow = client.create_flow(
|
||||
project_id=project_id,
|
||||
token=session_token,
|
||||
@@ -339,6 +341,7 @@ def _provision_default_flows_for_tenant(tenant_id):
|
||||
"displayName": flow_def.get("displayName", flow_type),
|
||||
"trigger": flow_def.get("trigger"),
|
||||
},
|
||||
folder_name="Defaults",
|
||||
)
|
||||
|
||||
flow_id = created_flow.get("id")
|
||||
@@ -348,8 +351,30 @@ def _provision_default_flows_for_tenant(tenant_id):
|
||||
)
|
||||
continue
|
||||
|
||||
# Enable the flow
|
||||
client.update_flow_status(flow_id, session_token, enabled=True)
|
||||
# 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=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
|
||||
TenantDefaultFlow.objects.create(
|
||||
|
||||
@@ -12,19 +12,133 @@ Flow structure follows Activepieces format:
|
||||
from typing import Dict, Any
|
||||
|
||||
# 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
|
||||
EMAIL_TEMPLATES = {
|
||||
"appointment_confirmation": "APPOINTMENT_CONFIRMATION",
|
||||
"appointment_reminder": "APPOINTMENT_REMINDER",
|
||||
"thank_you": "THANK_YOU",
|
||||
"payment_receipt": "PAYMENT_RECEIPT",
|
||||
# System email types for the send_email action
|
||||
EMAIL_TYPES = {
|
||||
"appointment_confirmation": "appointment_confirmation",
|
||||
"appointment_reminder": "appointment_reminder",
|
||||
"thank_you": "payment_receipt", # Use payment_receipt template for thank you
|
||||
"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(
|
||||
template_type: str,
|
||||
email_type: str,
|
||||
step_name: str = "send_email",
|
||||
next_action: Dict[str, Any] = None,
|
||||
) -> Dict[str, Any]:
|
||||
@@ -32,7 +146,7 @@ def _create_send_email_action(
|
||||
Create a send_email action step.
|
||||
|
||||
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
|
||||
next_action: Optional next action in the chain
|
||||
|
||||
@@ -52,16 +166,24 @@ def _create_send_email_action(
|
||||
"input": {
|
||||
# These use Activepieces interpolation syntax
|
||||
# {{trigger.customer.email}} references the trigger output
|
||||
"recipientEmail": "{{trigger.customer.email}}",
|
||||
"templateType": template_type,
|
||||
"to_email": "{{trigger.customer.email}}",
|
||||
"template_type": "system",
|
||||
"email_type": email_type,
|
||||
"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}}",
|
||||
"event_title": "{{trigger.event.title}}",
|
||||
"event_date": "{{trigger.event.start_time}}",
|
||||
"customer_phone": "{{trigger.customer.phone}}",
|
||||
"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": {
|
||||
@@ -102,7 +224,7 @@ def get_appointment_confirmation_flow() -> Dict[str, Any]:
|
||||
},
|
||||
},
|
||||
"nextAction": _create_send_email_action(
|
||||
template_type=EMAIL_TEMPLATES["appointment_confirmation"],
|
||||
email_type=EMAIL_TYPES["appointment_confirmation"],
|
||||
step_name="send_confirmation_email",
|
||||
),
|
||||
},
|
||||
@@ -149,15 +271,22 @@ def get_appointment_reminder_flow() -> Dict[str, Any]:
|
||||
"pieceType": "CUSTOM",
|
||||
"actionName": "send_email",
|
||||
"input": {
|
||||
"recipientEmail": "{{trigger.customer.email}}",
|
||||
"templateType": EMAIL_TEMPLATES["appointment_reminder"],
|
||||
"to_email": "{{trigger.customer.email}}",
|
||||
"template_type": "system",
|
||||
"email_type": EMAIL_TYPES["appointment_reminder"],
|
||||
"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}}",
|
||||
"event_title": "{{trigger.title}}",
|
||||
"event_date": "{{trigger.start_time}}",
|
||||
"customer_phone": "{{trigger.customer.phone}}",
|
||||
"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": {
|
||||
@@ -208,14 +337,20 @@ def get_thank_you_flow() -> Dict[str, Any]:
|
||||
"pieceType": "CUSTOM",
|
||||
"actionName": "send_email",
|
||||
"input": {
|
||||
"recipientEmail": "{{trigger.customer.email}}",
|
||||
"templateType": EMAIL_TEMPLATES["thank_you"],
|
||||
"to_email": "{{trigger.customer.email}}",
|
||||
"template_type": "system",
|
||||
"email_type": EMAIL_TYPES["thank_you"],
|
||||
"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}}",
|
||||
"event_title": "{{trigger.event.title}}",
|
||||
"customer_phone": "{{trigger.customer.phone}}",
|
||||
"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": {
|
||||
@@ -266,18 +401,23 @@ def get_deposit_payment_flow() -> Dict[str, Any]:
|
||||
"pieceType": "CUSTOM",
|
||||
"actionName": "send_email",
|
||||
"input": {
|
||||
"recipientEmail": "{{trigger.customer.email}}",
|
||||
"templateType": EMAIL_TEMPLATES["payment_receipt"],
|
||||
"subjectOverride": "Deposit Received - {{trigger.service.name}}",
|
||||
"to_email": "{{trigger.customer.email}}",
|
||||
"template_type": "system",
|
||||
"email_type": EMAIL_TYPES["payment_receipt"],
|
||||
"subject_override": "Deposit Received - {{trigger.service.name}}",
|
||||
"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}}",
|
||||
"event_title": "{{trigger.event.title}}",
|
||||
"event_date": "{{trigger.event.start_time}}",
|
||||
"customer_phone": "{{trigger.customer.phone}}",
|
||||
"service_name": "{{trigger.service.name}}",
|
||||
"amount": "{{trigger.amount}}",
|
||||
"payment_type": "deposit",
|
||||
"remaining_balance": "{{trigger.event.remaining_balance}}",
|
||||
"amount_paid": "{{trigger.amount}}",
|
||||
"invoice_number": "{{trigger.payment_intent_id}}",
|
||||
"deposit_amount": "{{trigger.amount}}",
|
||||
"total_paid": "{{trigger.amount}}",
|
||||
"appointment_date": "{{trigger.event.start_time}}",
|
||||
"appointment_datetime": "{{trigger.event.start_time}}",
|
||||
},
|
||||
},
|
||||
"inputUiInfo": {
|
||||
@@ -328,15 +468,21 @@ def get_final_payment_flow() -> Dict[str, Any]:
|
||||
"pieceType": "CUSTOM",
|
||||
"actionName": "send_email",
|
||||
"input": {
|
||||
"recipientEmail": "{{trigger.customer.email}}",
|
||||
"templateType": EMAIL_TEMPLATES["payment_receipt"],
|
||||
"to_email": "{{trigger.customer.email}}",
|
||||
"template_type": "system",
|
||||
"email_type": EMAIL_TYPES["payment_receipt"],
|
||||
"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}}",
|
||||
"event_title": "{{trigger.event.title}}",
|
||||
"customer_phone": "{{trigger.customer.phone}}",
|
||||
"service_name": "{{trigger.service.name}}",
|
||||
"amount": "{{trigger.amount}}",
|
||||
"payment_type": "final",
|
||||
"amount_paid": "{{trigger.amount}}",
|
||||
"invoice_number": "{{trigger.payment_intent_id}}",
|
||||
"total_paid": "{{trigger.amount}}",
|
||||
"appointment_date": "{{trigger.event.start_time}}",
|
||||
"appointment_datetime": "{{trigger.event.start_time}}",
|
||||
},
|
||||
},
|
||||
"inputUiInfo": {
|
||||
|
||||
@@ -316,7 +316,13 @@ class ActivepiecesClient:
|
||||
)
|
||||
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.
|
||||
|
||||
@@ -324,18 +330,25 @@ class ActivepiecesClient:
|
||||
project_id: The Activepieces project ID
|
||||
token: Session token for API calls
|
||||
flow_data: Flow definition including displayName and trigger
|
||||
folder_name: Optional folder name to create/use for this flow
|
||||
|
||||
Returns:
|
||||
Created flow object with id
|
||||
"""
|
||||
# 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(
|
||||
"POST",
|
||||
"/api/v1/flows",
|
||||
data={
|
||||
"displayName": flow_data.get("displayName", "Untitled"),
|
||||
"projectId": project_id,
|
||||
},
|
||||
data=create_data,
|
||||
token=token,
|
||||
)
|
||||
|
||||
@@ -407,6 +420,69 @@ class ActivepiecesClient:
|
||||
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:
|
||||
"""
|
||||
Get a flow by ID.
|
||||
@@ -470,6 +546,34 @@ class ActivepiecesClient:
|
||||
"""
|
||||
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:
|
||||
"""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 .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__)
|
||||
|
||||
@@ -291,9 +291,8 @@ class DefaultFlowRestoreView(TenantRequiredAPIView, APIView):
|
||||
client = get_activepieces_client()
|
||||
|
||||
try:
|
||||
# Get session for this tenant
|
||||
session_data = client.get_embed_session(tenant)
|
||||
token = session_data.get("token")
|
||||
# Get session token for API calls (not trust token for frontend)
|
||||
token, project_id = client.get_session_token(tenant)
|
||||
|
||||
if not token:
|
||||
return self.error_response(
|
||||
@@ -304,15 +303,61 @@ class DefaultFlowRestoreView(TenantRequiredAPIView, APIView):
|
||||
# Get the original flow definition
|
||||
flow_def = get_flow_definition(flow_type)
|
||||
|
||||
# Update the flow in Activepieces using import_flow
|
||||
client.import_flow(
|
||||
flow_id=default_flow.activepieces_flow_id,
|
||||
token=token,
|
||||
display_name=flow_def.get("displayName", flow_type),
|
||||
trigger=flow_def.get("trigger"),
|
||||
)
|
||||
# Try to update the existing flow, or create a new one if it doesn't exist
|
||||
try:
|
||||
client.import_flow(
|
||||
flow_id=default_flow.activepieces_flow_id,
|
||||
token=token,
|
||||
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
|
||||
default_flow.activepieces_flow_id = new_flow_id
|
||||
default_flow.is_modified = False
|
||||
default_flow.default_flow_json = flow_def
|
||||
default_flow.version = FLOW_VERSION
|
||||
@@ -361,9 +406,8 @@ class DefaultFlowsRestoreAllView(TenantRequiredAPIView, APIView):
|
||||
client = get_activepieces_client()
|
||||
|
||||
try:
|
||||
# Get session for this tenant
|
||||
session_data = client.get_embed_session(tenant)
|
||||
token = session_data.get("token")
|
||||
# Get session token for API calls (not trust token for frontend)
|
||||
token, project_id = client.get_session_token(tenant)
|
||||
|
||||
if not token:
|
||||
return self.error_response(
|
||||
@@ -382,15 +426,65 @@ class DefaultFlowsRestoreAllView(TenantRequiredAPIView, APIView):
|
||||
# Get the original flow definition
|
||||
flow_def = get_flow_definition(default_flow.flow_type)
|
||||
|
||||
# Update the flow in Activepieces
|
||||
client.import_flow(
|
||||
flow_id=default_flow.activepieces_flow_id,
|
||||
token=token,
|
||||
display_name=flow_def.get("displayName", default_flow.flow_type),
|
||||
trigger=flow_def.get("trigger"),
|
||||
)
|
||||
# Try to update the existing flow, or create a new one if it doesn't exist
|
||||
try:
|
||||
client.import_flow(
|
||||
flow_id=default_flow.activepieces_flow_id,
|
||||
token=token,
|
||||
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
|
||||
default_flow.activepieces_flow_id = new_flow_id
|
||||
default_flow.is_modified = False
|
||||
default_flow.default_flow_json = flow_def
|
||||
default_flow.version = FLOW_VERSION
|
||||
|
||||
@@ -2454,6 +2454,20 @@ class SendEmailView(PublicAPIViewMixin, APIView):
|
||||
if 'business_website_url' not in context and hasattr(tenant, 'website'):
|
||||
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
|
||||
from django.utils import timezone
|
||||
now = timezone.now()
|
||||
|
||||
Reference in New Issue
Block a user