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:
poduck
2025-12-21 23:38:10 -05:00
parent ac3115a5a1
commit 7b380fa903
14 changed files with 786 additions and 149 deletions

View File

@@ -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

View File

@@ -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,
});

View File

@@ -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>

View File

@@ -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',
})

View File

@@ -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

View 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;
};

View 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() });
},
});
};

View File

@@ -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"

View File

@@ -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

View File

@@ -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(

View File

@@ -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": {

View File

@@ -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."""

View File

@@ -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

View File

@@ -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()