Add default automation flows for tenants
Features:
- Auto-provision 5 default email flows for each new tenant:
- Appointment Confirmation (on event created)
- Appointment Reminder (X hours before, per service settings)
- Thank You Email (on final payment)
- Deposit Payment Confirmation
- Final Payment Confirmation
- New SmoothSchedule piece triggers:
- payment_received: Polls for new payments (deposit/final)
- upcoming_events: Polls for events starting within X hours
- New SmoothSchedule piece action:
- list_customers: List customers with search, pagination
- Backend APIs:
- GET /api/v1/payments/ for payment trigger polling
- GET /api/v1/events/upcoming/ for reminder trigger
- Restore functionality:
- GET /api/activepieces/default-flows/ to list default flows
- POST /api/activepieces/default-flows/{type}/restore/ to restore one
- POST /api/activepieces/default-flows/restore-all/ to restore all
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -5,9 +5,10 @@ import { createEventAction, findEventsAction, updateEventAction, cancelEventActi
|
|||||||
import { listResourcesAction } from './lib/actions/list-resources';
|
import { listResourcesAction } from './lib/actions/list-resources';
|
||||||
import { listServicesAction } from './lib/actions/list-services';
|
import { listServicesAction } from './lib/actions/list-services';
|
||||||
import { listInactiveCustomersAction } from './lib/actions/list-inactive-customers';
|
import { listInactiveCustomersAction } from './lib/actions/list-inactive-customers';
|
||||||
|
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 } from './lib/triggers';
|
import { eventCreatedTrigger, eventUpdatedTrigger, eventCancelledTrigger, eventStatusChangedTrigger, paymentReceivedTrigger, upcomingEventsTrigger } from './lib/triggers';
|
||||||
import { API_URL } from './lib/common';
|
import { API_URL } from './lib/common';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -75,6 +76,7 @@ export const smoothSchedule = createPiece({
|
|||||||
listResourcesAction,
|
listResourcesAction,
|
||||||
listServicesAction,
|
listServicesAction,
|
||||||
listInactiveCustomersAction,
|
listInactiveCustomersAction,
|
||||||
|
listCustomersAction,
|
||||||
sendEmailAction,
|
sendEmailAction,
|
||||||
listEmailTemplatesAction,
|
listEmailTemplatesAction,
|
||||||
createCustomApiCallAction({
|
createCustomApiCallAction({
|
||||||
@@ -89,5 +91,5 @@ export const smoothSchedule = createPiece({
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
triggers: [eventCreatedTrigger, eventUpdatedTrigger, eventCancelledTrigger, eventStatusChangedTrigger],
|
triggers: [eventCreatedTrigger, eventUpdatedTrigger, eventCancelledTrigger, eventStatusChangedTrigger, paymentReceivedTrigger, upcomingEventsTrigger],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,3 +5,4 @@ export * from './find-events';
|
|||||||
export * from './list-resources';
|
export * from './list-resources';
|
||||||
export * from './list-services';
|
export * from './list-services';
|
||||||
export * from './list-inactive-customers';
|
export * from './list-inactive-customers';
|
||||||
|
export * from './list-customers';
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { Property, createAction } from '@activepieces/pieces-framework';
|
||||||
|
import { HttpMethod } from '@activepieces/pieces-common';
|
||||||
|
import { smoothScheduleAuth, SmoothScheduleAuth } from '../../index';
|
||||||
|
import { makeRequest } from '../common';
|
||||||
|
|
||||||
|
interface PaginatedResponse<T> {
|
||||||
|
results: T[];
|
||||||
|
count: number;
|
||||||
|
next: string | null;
|
||||||
|
previous: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Customer {
|
||||||
|
id: number;
|
||||||
|
email: string;
|
||||||
|
first_name: string;
|
||||||
|
last_name: string;
|
||||||
|
phone: string;
|
||||||
|
notes: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const listCustomersAction = createAction({
|
||||||
|
auth: smoothScheduleAuth,
|
||||||
|
name: 'list_customers',
|
||||||
|
displayName: 'List Customers',
|
||||||
|
description: 'Get a list of customers from SmoothSchedule. Useful for customer lookup and bulk operations.',
|
||||||
|
props: {
|
||||||
|
search: Property.ShortText({
|
||||||
|
displayName: 'Search',
|
||||||
|
description: 'Search by name, email, or phone number',
|
||||||
|
required: false,
|
||||||
|
}),
|
||||||
|
limit: Property.Number({
|
||||||
|
displayName: 'Limit',
|
||||||
|
description: 'Maximum number of customers to return (default: 50, max: 500)',
|
||||||
|
required: false,
|
||||||
|
defaultValue: 50,
|
||||||
|
}),
|
||||||
|
offset: Property.Number({
|
||||||
|
displayName: 'Offset',
|
||||||
|
description: 'Number of customers to skip (for pagination)',
|
||||||
|
required: false,
|
||||||
|
defaultValue: 0,
|
||||||
|
}),
|
||||||
|
orderBy: Property.StaticDropdown({
|
||||||
|
displayName: 'Order By',
|
||||||
|
description: 'Sort order for results',
|
||||||
|
required: false,
|
||||||
|
options: {
|
||||||
|
options: [
|
||||||
|
{ label: 'Newest First', value: '-created_at' },
|
||||||
|
{ label: 'Oldest First', value: 'created_at' },
|
||||||
|
{ label: 'Name (A-Z)', value: 'first_name' },
|
||||||
|
{ label: 'Name (Z-A)', value: '-first_name' },
|
||||||
|
{ label: 'Email (A-Z)', value: 'email' },
|
||||||
|
{ label: 'Last Updated', value: '-updated_at' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
defaultValue: '-created_at',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
async run(context) {
|
||||||
|
const auth = context.auth as SmoothScheduleAuth;
|
||||||
|
const props = context.propsValue;
|
||||||
|
|
||||||
|
const queryParams: Record<string, string> = {};
|
||||||
|
|
||||||
|
if (props.search) {
|
||||||
|
queryParams['search'] = props.search;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clamp limit to reasonable range
|
||||||
|
const limit = Math.min(Math.max(props.limit || 50, 1), 500);
|
||||||
|
queryParams['limit'] = limit.toString();
|
||||||
|
|
||||||
|
if (props.offset && props.offset > 0) {
|
||||||
|
queryParams['offset'] = props.offset.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.orderBy) {
|
||||||
|
queryParams['ordering'] = props.orderBy;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await makeRequest<PaginatedResponse<Customer>>(
|
||||||
|
auth,
|
||||||
|
HttpMethod.GET,
|
||||||
|
'/customers/',
|
||||||
|
undefined,
|
||||||
|
queryParams
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
customers: response.results || [],
|
||||||
|
total_count: response.count || 0,
|
||||||
|
has_more: response.next !== null,
|
||||||
|
limit: limit,
|
||||||
|
offset: props.offset || 0,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -2,3 +2,5 @@ export * from './event-created';
|
|||||||
export * from './event-updated';
|
export * from './event-updated';
|
||||||
export * from './event-cancelled';
|
export * from './event-cancelled';
|
||||||
export * from './event-status-changed';
|
export * from './event-status-changed';
|
||||||
|
export * from './payment-received';
|
||||||
|
export * from './upcoming-events';
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
import { createTrigger, TriggerStrategy, Property } from '@activepieces/pieces-framework';
|
||||||
|
import { HttpMethod } from '@activepieces/pieces-common';
|
||||||
|
import { smoothScheduleAuth, SmoothScheduleAuth } from '../../index';
|
||||||
|
import { makeRequest } from '../common';
|
||||||
|
|
||||||
|
const TRIGGER_KEY = 'last_payment_check_timestamp';
|
||||||
|
|
||||||
|
interface PaymentData {
|
||||||
|
id: number;
|
||||||
|
payment_intent_id: string;
|
||||||
|
amount: string;
|
||||||
|
currency: string;
|
||||||
|
type: 'deposit' | 'final';
|
||||||
|
status: string;
|
||||||
|
created_at: string;
|
||||||
|
completed_at: string;
|
||||||
|
event: {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
start_time: string;
|
||||||
|
end_time: string;
|
||||||
|
status: string;
|
||||||
|
deposit_amount: string | null;
|
||||||
|
final_price: string | null;
|
||||||
|
remaining_balance: string | null;
|
||||||
|
};
|
||||||
|
service: {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
price: string;
|
||||||
|
} | null;
|
||||||
|
customer: {
|
||||||
|
id: number;
|
||||||
|
first_name: string;
|
||||||
|
last_name: string;
|
||||||
|
email: string;
|
||||||
|
phone: string;
|
||||||
|
} | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const paymentReceivedTrigger = createTrigger({
|
||||||
|
auth: smoothScheduleAuth,
|
||||||
|
name: 'payment_received',
|
||||||
|
displayName: 'Payment Received',
|
||||||
|
description: 'Triggers when a payment is successfully completed in SmoothSchedule.',
|
||||||
|
props: {
|
||||||
|
paymentType: Property.StaticDropdown({
|
||||||
|
displayName: 'Payment Type',
|
||||||
|
description: 'Only trigger for specific payment types',
|
||||||
|
required: false,
|
||||||
|
options: {
|
||||||
|
options: [
|
||||||
|
{ label: 'All Payments', value: 'all' },
|
||||||
|
{ label: 'Deposit Payments', value: 'deposit' },
|
||||||
|
{ label: 'Final Payments', value: 'final' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
defaultValue: 'all',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
type: TriggerStrategy.POLLING,
|
||||||
|
async onEnable(context) {
|
||||||
|
// Store current timestamp as starting point
|
||||||
|
await context.store.put(TRIGGER_KEY, new Date().toISOString());
|
||||||
|
},
|
||||||
|
async onDisable(context) {
|
||||||
|
await context.store.delete(TRIGGER_KEY);
|
||||||
|
},
|
||||||
|
async test(context) {
|
||||||
|
const auth = context.auth as SmoothScheduleAuth;
|
||||||
|
const { paymentType } = context.propsValue;
|
||||||
|
|
||||||
|
const queryParams: Record<string, string> = {
|
||||||
|
limit: '5',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (paymentType && paymentType !== 'all') {
|
||||||
|
queryParams['type'] = paymentType;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payments = await makeRequest<PaymentData[]>(
|
||||||
|
auth,
|
||||||
|
HttpMethod.GET,
|
||||||
|
'/payments/',
|
||||||
|
undefined,
|
||||||
|
queryParams
|
||||||
|
);
|
||||||
|
|
||||||
|
return payments;
|
||||||
|
},
|
||||||
|
async run(context) {
|
||||||
|
const auth = context.auth as SmoothScheduleAuth;
|
||||||
|
const { paymentType } = context.propsValue;
|
||||||
|
|
||||||
|
const lastCheck = await context.store.get<string>(TRIGGER_KEY) || new Date(0).toISOString();
|
||||||
|
|
||||||
|
const queryParams: Record<string, string> = {
|
||||||
|
'created_at__gt': lastCheck,
|
||||||
|
limit: '100',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (paymentType && paymentType !== 'all') {
|
||||||
|
queryParams['type'] = paymentType;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payments = await makeRequest<PaymentData[]>(
|
||||||
|
auth,
|
||||||
|
HttpMethod.GET,
|
||||||
|
'/payments/',
|
||||||
|
undefined,
|
||||||
|
queryParams
|
||||||
|
);
|
||||||
|
|
||||||
|
if (payments.length > 0) {
|
||||||
|
// Update the last check timestamp to the most recent payment
|
||||||
|
const mostRecent = payments.reduce((latest, p) =>
|
||||||
|
new Date(p.completed_at) > new Date(latest.completed_at) ? p : latest
|
||||||
|
);
|
||||||
|
await context.store.put(TRIGGER_KEY, mostRecent.completed_at);
|
||||||
|
}
|
||||||
|
|
||||||
|
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',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
import { createTrigger, TriggerStrategy, Property } from '@activepieces/pieces-framework';
|
||||||
|
import { HttpMethod } from '@activepieces/pieces-common';
|
||||||
|
import { smoothScheduleAuth, SmoothScheduleAuth } from '../../index';
|
||||||
|
import { makeRequest } from '../common';
|
||||||
|
|
||||||
|
const TRIGGER_KEY_PREFIX = 'reminder_sent_event_ids';
|
||||||
|
|
||||||
|
interface UpcomingEventData {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
start_time: string;
|
||||||
|
end_time: string;
|
||||||
|
status: string;
|
||||||
|
hours_until_start: number;
|
||||||
|
reminder_hours_before: number;
|
||||||
|
should_send_reminder: boolean;
|
||||||
|
service: {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
duration: number;
|
||||||
|
price: string;
|
||||||
|
reminder_enabled: boolean;
|
||||||
|
reminder_hours_before: number;
|
||||||
|
} | null;
|
||||||
|
customer: {
|
||||||
|
id: number;
|
||||||
|
first_name: string;
|
||||||
|
last_name: string;
|
||||||
|
email: string;
|
||||||
|
phone: string;
|
||||||
|
} | null;
|
||||||
|
resources: Array<{
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
}>;
|
||||||
|
notes: string | null;
|
||||||
|
location: {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
address: string;
|
||||||
|
} | null;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const upcomingEventsTrigger = createTrigger({
|
||||||
|
auth: smoothScheduleAuth,
|
||||||
|
name: 'upcoming_events',
|
||||||
|
displayName: 'Upcoming Event (Reminder)',
|
||||||
|
description: 'Triggers for events starting soon. Use for sending appointment reminders.',
|
||||||
|
props: {
|
||||||
|
hoursAhead: Property.Number({
|
||||||
|
displayName: 'Hours Ahead',
|
||||||
|
description: 'Trigger for events starting within this many hours (matches service reminder settings)',
|
||||||
|
required: false,
|
||||||
|
defaultValue: 24,
|
||||||
|
}),
|
||||||
|
onlyIfReminderEnabled: Property.Checkbox({
|
||||||
|
displayName: 'Only if Reminder Enabled',
|
||||||
|
description: 'Only trigger for events where the service has reminders enabled',
|
||||||
|
required: false,
|
||||||
|
defaultValue: true,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
type: TriggerStrategy.POLLING,
|
||||||
|
async onEnable(context) {
|
||||||
|
// Initialize with empty set of processed event IDs
|
||||||
|
await context.store.put(TRIGGER_KEY_PREFIX, JSON.stringify([]));
|
||||||
|
},
|
||||||
|
async onDisable(context) {
|
||||||
|
await context.store.delete(TRIGGER_KEY_PREFIX);
|
||||||
|
},
|
||||||
|
async test(context) {
|
||||||
|
const auth = context.auth as SmoothScheduleAuth;
|
||||||
|
const { hoursAhead } = context.propsValue;
|
||||||
|
|
||||||
|
const queryParams: Record<string, string> = {
|
||||||
|
hours_ahead: String(hoursAhead || 24),
|
||||||
|
limit: '5',
|
||||||
|
};
|
||||||
|
|
||||||
|
const events = await makeRequest<UpcomingEventData[]>(
|
||||||
|
auth,
|
||||||
|
HttpMethod.GET,
|
||||||
|
'/events/upcoming/',
|
||||||
|
undefined,
|
||||||
|
queryParams
|
||||||
|
);
|
||||||
|
|
||||||
|
return events;
|
||||||
|
},
|
||||||
|
async run(context) {
|
||||||
|
const auth = context.auth as SmoothScheduleAuth;
|
||||||
|
const { hoursAhead, onlyIfReminderEnabled } = context.propsValue;
|
||||||
|
|
||||||
|
// Get list of event IDs we've already processed
|
||||||
|
const processedIdsJson = await context.store.get<string>(TRIGGER_KEY_PREFIX) || '[]';
|
||||||
|
let processedIds: number[] = [];
|
||||||
|
try {
|
||||||
|
processedIds = JSON.parse(processedIdsJson);
|
||||||
|
} catch {
|
||||||
|
processedIds = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const queryParams: Record<string, string> = {
|
||||||
|
hours_ahead: String(hoursAhead || 24),
|
||||||
|
limit: '100',
|
||||||
|
};
|
||||||
|
|
||||||
|
const events = await makeRequest<UpcomingEventData[]>(
|
||||||
|
auth,
|
||||||
|
HttpMethod.GET,
|
||||||
|
'/events/upcoming/',
|
||||||
|
undefined,
|
||||||
|
queryParams
|
||||||
|
);
|
||||||
|
|
||||||
|
// Filter to only events that should trigger reminders
|
||||||
|
let filteredEvents = events.filter((event) => {
|
||||||
|
// Skip if already processed
|
||||||
|
if (processedIds.includes(event.id)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if reminder is appropriate based on service settings
|
||||||
|
if (!event.should_send_reminder) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if service has reminders enabled
|
||||||
|
if (onlyIfReminderEnabled && event.service && !event.service.reminder_enabled) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update the processed IDs list
|
||||||
|
if (filteredEvents.length > 0) {
|
||||||
|
const newProcessedIds = [...processedIds, ...filteredEvents.map((e) => e.id)];
|
||||||
|
// Keep only last 1000 IDs to prevent unbounded growth
|
||||||
|
const trimmedIds = newProcessedIds.slice(-1000);
|
||||||
|
await context.store.put(TRIGGER_KEY_PREFIX, JSON.stringify(trimmedIds));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also clean up old IDs (events that have already passed)
|
||||||
|
// This runs periodically to keep the list manageable
|
||||||
|
if (Math.random() < 0.1) { // 10% of runs
|
||||||
|
const currentIds = events.map((e) => e.id);
|
||||||
|
const activeProcessedIds = processedIds.filter((id) => currentIds.includes(id));
|
||||||
|
await context.store.put(TRIGGER_KEY_PREFIX, JSON.stringify(activeProcessedIds));
|
||||||
|
}
|
||||||
|
|
||||||
|
return filteredEvents;
|
||||||
|
},
|
||||||
|
sampleData: {
|
||||||
|
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,
|
||||||
|
service: {
|
||||||
|
id: 1,
|
||||||
|
name: 'Consultation',
|
||||||
|
duration: 60,
|
||||||
|
price: '200.00',
|
||||||
|
reminder_enabled: true,
|
||||||
|
reminder_hours_before: 24,
|
||||||
|
},
|
||||||
|
customer: {
|
||||||
|
id: 50,
|
||||||
|
first_name: 'John',
|
||||||
|
last_name: 'Doe',
|
||||||
|
email: 'john.doe@example.com',
|
||||||
|
phone: '+1-555-0100',
|
||||||
|
},
|
||||||
|
resources: [
|
||||||
|
{ id: 1, name: 'Dr. Smith' },
|
||||||
|
],
|
||||||
|
notes: 'First-time client',
|
||||||
|
location: {
|
||||||
|
id: 1,
|
||||||
|
name: 'Main Office',
|
||||||
|
address: '123 Business St',
|
||||||
|
},
|
||||||
|
created_at: '2024-12-01T10:00:00Z',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -247,3 +247,162 @@ def provision_activepieces_on_tenant_create(sender, instance, created, **kwargs)
|
|||||||
tenant_id = instance.id
|
tenant_id = instance.id
|
||||||
# Use a delay to ensure all other tenant setup is complete
|
# Use a delay to ensure all other tenant setup is complete
|
||||||
transaction.on_commit(lambda: _provision_activepieces_connection(tenant_id))
|
transaction.on_commit(lambda: _provision_activepieces_connection(tenant_id))
|
||||||
|
|
||||||
|
|
||||||
|
def _provision_default_flows_for_tenant(tenant_id):
|
||||||
|
"""
|
||||||
|
Provision default automation flows in Activepieces for a tenant.
|
||||||
|
Called after Activepieces connection is provisioned.
|
||||||
|
"""
|
||||||
|
from smoothschedule.identity.core.models import Tenant
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
# Only provision if Activepieces is configured
|
||||||
|
if not getattr(settings, 'ACTIVEPIECES_JWT_SECRET', ''):
|
||||||
|
logger.debug("Activepieces not configured, skipping default flows")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
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'):
|
||||||
|
logger.debug(
|
||||||
|
f"Tenant {tenant.schema_name} doesn't have automation feature, "
|
||||||
|
"skipping default flows"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Import here to avoid circular imports
|
||||||
|
from smoothschedule.integrations.activepieces.services import (
|
||||||
|
get_activepieces_client,
|
||||||
|
)
|
||||||
|
from smoothschedule.integrations.activepieces.models import (
|
||||||
|
TenantActivepiecesProject,
|
||||||
|
TenantDefaultFlow,
|
||||||
|
)
|
||||||
|
from smoothschedule.integrations.activepieces.default_flows import (
|
||||||
|
get_all_flow_definitions,
|
||||||
|
FLOW_VERSION,
|
||||||
|
)
|
||||||
|
from django_tenants.utils import schema_context
|
||||||
|
|
||||||
|
# Get or create Activepieces project for this tenant
|
||||||
|
try:
|
||||||
|
project = TenantActivepiecesProject.objects.get(tenant=tenant)
|
||||||
|
except TenantActivepiecesProject.DoesNotExist:
|
||||||
|
logger.warning(
|
||||||
|
f"No Activepieces project for tenant {tenant.schema_name}, "
|
||||||
|
"skipping default flows"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
client = get_activepieces_client()
|
||||||
|
|
||||||
|
# Get a session token for API calls
|
||||||
|
provisioning_token = client._generate_trust_token(tenant)
|
||||||
|
result = client._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:
|
||||||
|
logger.error(
|
||||||
|
f"Failed to get Activepieces session for tenant {tenant.schema_name}"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Create each default flow
|
||||||
|
flow_definitions = get_all_flow_definitions()
|
||||||
|
created_count = 0
|
||||||
|
|
||||||
|
with schema_context(tenant.schema_name):
|
||||||
|
for flow_type, flow_def in flow_definitions.items():
|
||||||
|
# Check if flow already exists
|
||||||
|
if TenantDefaultFlow.objects.filter(
|
||||||
|
tenant=tenant, flow_type=flow_type
|
||||||
|
).exists():
|
||||||
|
logger.debug(
|
||||||
|
f"Default flow {flow_type} already exists for tenant {tenant.schema_name}"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Create the flow in Activepieces
|
||||||
|
created_flow = client.create_flow(
|
||||||
|
project_id=project_id,
|
||||||
|
token=session_token,
|
||||||
|
flow_data={
|
||||||
|
"displayName": flow_def.get("displayName", flow_type),
|
||||||
|
"trigger": flow_def.get("trigger"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
flow_id = created_flow.get("id")
|
||||||
|
if not flow_id:
|
||||||
|
logger.error(
|
||||||
|
f"Failed to create flow {flow_type} for tenant {tenant.schema_name}"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Enable the flow
|
||||||
|
client.update_flow_status(flow_id, session_token, enabled=True)
|
||||||
|
|
||||||
|
# Store the flow record in Django
|
||||||
|
TenantDefaultFlow.objects.create(
|
||||||
|
tenant=tenant,
|
||||||
|
flow_type=flow_type,
|
||||||
|
activepieces_flow_id=flow_id,
|
||||||
|
default_flow_json=flow_def,
|
||||||
|
version=FLOW_VERSION,
|
||||||
|
is_enabled=True,
|
||||||
|
is_modified=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
created_count += 1
|
||||||
|
logger.info(
|
||||||
|
f"Created default flow {flow_type} for tenant {tenant.schema_name}"
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
f"Failed to create flow {flow_type} for tenant {tenant.schema_name}: {e}"
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Provisioned {created_count} default flows for tenant: {tenant.schema_name}"
|
||||||
|
)
|
||||||
|
|
||||||
|
except Tenant.DoesNotExist:
|
||||||
|
logger.error(f"Tenant {tenant_id} not found when provisioning default flows")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to provision default flows for tenant {tenant_id}: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
@receiver(post_save, sender='core.Tenant')
|
||||||
|
def provision_default_flows_on_tenant_create(sender, instance, created, **kwargs):
|
||||||
|
"""
|
||||||
|
Provision default automation flows when a new tenant is created.
|
||||||
|
|
||||||
|
This creates the standard email automation flows (confirmation, reminder,
|
||||||
|
thank you, payment confirmations) so tenants have working automations
|
||||||
|
out of the box.
|
||||||
|
|
||||||
|
Runs after Activepieces connection provisioning via on_commit with a delayed
|
||||||
|
lambda to ensure the connection exists first.
|
||||||
|
"""
|
||||||
|
if not created:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Skip public schema
|
||||||
|
if instance.schema_name == 'public':
|
||||||
|
return
|
||||||
|
|
||||||
|
tenant_id = instance.id
|
||||||
|
# Use a second on_commit to run after the connection provisioning
|
||||||
|
# The ordering of on_commit callbacks is preserved, so this will run
|
||||||
|
# after _provision_activepieces_connection
|
||||||
|
transaction.on_commit(lambda: _provision_default_flows_for_tenant(tenant_id))
|
||||||
|
|||||||
@@ -0,0 +1,391 @@
|
|||||||
|
"""
|
||||||
|
Default flow definitions for auto-provisioning.
|
||||||
|
|
||||||
|
Each flow uses SmoothSchedule piece triggers and actions to create
|
||||||
|
standard email automation workflows for every tenant.
|
||||||
|
|
||||||
|
Flow structure follows Activepieces format:
|
||||||
|
- trigger: The trigger configuration (polling or webhook)
|
||||||
|
- Each action is nested in the previous step's "nextAction" field
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Dict, Any
|
||||||
|
|
||||||
|
# Version for tracking upgrades
|
||||||
|
FLOW_VERSION = "1.0.0"
|
||||||
|
|
||||||
|
# Template types for email actions
|
||||||
|
EMAIL_TEMPLATES = {
|
||||||
|
"appointment_confirmation": "APPOINTMENT_CONFIRMATION",
|
||||||
|
"appointment_reminder": "APPOINTMENT_REMINDER",
|
||||||
|
"thank_you": "THANK_YOU",
|
||||||
|
"payment_receipt": "PAYMENT_RECEIPT",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _create_send_email_action(
|
||||||
|
template_type: str,
|
||||||
|
step_name: str = "send_email",
|
||||||
|
next_action: Dict[str, Any] = None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Create a send_email action step.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
template_type: The email template to use (e.g., "APPOINTMENT_CONFIRMATION")
|
||||||
|
step_name: Unique step name
|
||||||
|
next_action: Optional next action in the chain
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Action definition dict
|
||||||
|
"""
|
||||||
|
action = {
|
||||||
|
"name": step_name,
|
||||||
|
"displayName": "Send Email",
|
||||||
|
"type": "PIECE",
|
||||||
|
"valid": True,
|
||||||
|
"settings": {
|
||||||
|
"pieceName": "@activepieces/piece-smoothschedule",
|
||||||
|
"pieceVersion": "~0.0.1",
|
||||||
|
"pieceType": "CUSTOM",
|
||||||
|
"actionName": "send_email",
|
||||||
|
"input": {
|
||||||
|
# These use Activepieces interpolation syntax
|
||||||
|
# {{trigger.customer.email}} references the trigger output
|
||||||
|
"recipientEmail": "{{trigger.customer.email}}",
|
||||||
|
"templateType": template_type,
|
||||||
|
"context": {
|
||||||
|
# Map trigger data to template context
|
||||||
|
"customer_name": "{{trigger.customer.first_name}}",
|
||||||
|
"customer_email": "{{trigger.customer.email}}",
|
||||||
|
"event_title": "{{trigger.event.title}}",
|
||||||
|
"event_date": "{{trigger.event.start_time}}",
|
||||||
|
"service_name": "{{trigger.service.name}}",
|
||||||
|
"amount": "{{trigger.amount}}",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"inputUiInfo": {
|
||||||
|
"customizedInputs": {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if next_action:
|
||||||
|
action["nextAction"] = next_action
|
||||||
|
|
||||||
|
return action
|
||||||
|
|
||||||
|
|
||||||
|
def get_appointment_confirmation_flow() -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Appointment Confirmation Flow
|
||||||
|
|
||||||
|
Trigger: When a new event is created with status SCHEDULED
|
||||||
|
Action: Send appointment confirmation email
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
"displayName": "Appointment Confirmation Email",
|
||||||
|
"description": "Automatically send a confirmation email when an appointment is booked",
|
||||||
|
"trigger": {
|
||||||
|
"name": "trigger",
|
||||||
|
"displayName": "Event Created",
|
||||||
|
"type": "PIECE_TRIGGER",
|
||||||
|
"valid": True,
|
||||||
|
"settings": {
|
||||||
|
"pieceName": "@activepieces/piece-smoothschedule",
|
||||||
|
"pieceVersion": "~0.0.1",
|
||||||
|
"pieceType": "CUSTOM",
|
||||||
|
"triggerName": "event_created",
|
||||||
|
"input": {},
|
||||||
|
"inputUiInfo": {
|
||||||
|
"customizedInputs": {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"nextAction": _create_send_email_action(
|
||||||
|
template_type=EMAIL_TEMPLATES["appointment_confirmation"],
|
||||||
|
step_name="send_confirmation_email",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"schemaVersion": "1",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_appointment_reminder_flow() -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Appointment Reminder Flow
|
||||||
|
|
||||||
|
Trigger: Upcoming events (based on service reminder settings)
|
||||||
|
Action: Send reminder email
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
"displayName": "Appointment Reminder Email",
|
||||||
|
"description": "Send reminder emails before appointments (based on service settings)",
|
||||||
|
"trigger": {
|
||||||
|
"name": "trigger",
|
||||||
|
"displayName": "Upcoming Event",
|
||||||
|
"type": "PIECE_TRIGGER",
|
||||||
|
"valid": True,
|
||||||
|
"settings": {
|
||||||
|
"pieceName": "@activepieces/piece-smoothschedule",
|
||||||
|
"pieceVersion": "~0.0.1",
|
||||||
|
"pieceType": "CUSTOM",
|
||||||
|
"triggerName": "upcoming_events",
|
||||||
|
"input": {
|
||||||
|
"hoursAhead": 24,
|
||||||
|
"onlyIfReminderEnabled": True,
|
||||||
|
},
|
||||||
|
"inputUiInfo": {
|
||||||
|
"customizedInputs": {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"nextAction": {
|
||||||
|
"name": "send_reminder_email",
|
||||||
|
"displayName": "Send Reminder Email",
|
||||||
|
"type": "PIECE",
|
||||||
|
"valid": True,
|
||||||
|
"settings": {
|
||||||
|
"pieceName": "@activepieces/piece-smoothschedule",
|
||||||
|
"pieceVersion": "~0.0.1",
|
||||||
|
"pieceType": "CUSTOM",
|
||||||
|
"actionName": "send_email",
|
||||||
|
"input": {
|
||||||
|
"recipientEmail": "{{trigger.customer.email}}",
|
||||||
|
"templateType": EMAIL_TEMPLATES["appointment_reminder"],
|
||||||
|
"context": {
|
||||||
|
"customer_name": "{{trigger.customer.first_name}}",
|
||||||
|
"customer_email": "{{trigger.customer.email}}",
|
||||||
|
"event_title": "{{trigger.title}}",
|
||||||
|
"event_date": "{{trigger.start_time}}",
|
||||||
|
"service_name": "{{trigger.service.name}}",
|
||||||
|
"hours_until": "{{trigger.hours_until_start}}",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"inputUiInfo": {
|
||||||
|
"customizedInputs": {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"schemaVersion": "1",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_thank_you_flow() -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Thank You Email Flow
|
||||||
|
|
||||||
|
Trigger: When a final payment is received
|
||||||
|
Action: Send thank you email
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
"displayName": "Thank You Email (After Payment)",
|
||||||
|
"description": "Send a thank you email when final payment is completed",
|
||||||
|
"trigger": {
|
||||||
|
"name": "trigger",
|
||||||
|
"displayName": "Payment Received",
|
||||||
|
"type": "PIECE_TRIGGER",
|
||||||
|
"valid": True,
|
||||||
|
"settings": {
|
||||||
|
"pieceName": "@activepieces/piece-smoothschedule",
|
||||||
|
"pieceVersion": "~0.0.1",
|
||||||
|
"pieceType": "CUSTOM",
|
||||||
|
"triggerName": "payment_received",
|
||||||
|
"input": {
|
||||||
|
"paymentType": "final",
|
||||||
|
},
|
||||||
|
"inputUiInfo": {
|
||||||
|
"customizedInputs": {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"nextAction": {
|
||||||
|
"name": "send_thank_you_email",
|
||||||
|
"displayName": "Send Thank You Email",
|
||||||
|
"type": "PIECE",
|
||||||
|
"valid": True,
|
||||||
|
"settings": {
|
||||||
|
"pieceName": "@activepieces/piece-smoothschedule",
|
||||||
|
"pieceVersion": "~0.0.1",
|
||||||
|
"pieceType": "CUSTOM",
|
||||||
|
"actionName": "send_email",
|
||||||
|
"input": {
|
||||||
|
"recipientEmail": "{{trigger.customer.email}}",
|
||||||
|
"templateType": EMAIL_TEMPLATES["thank_you"],
|
||||||
|
"context": {
|
||||||
|
"customer_name": "{{trigger.customer.first_name}}",
|
||||||
|
"customer_email": "{{trigger.customer.email}}",
|
||||||
|
"event_title": "{{trigger.event.title}}",
|
||||||
|
"service_name": "{{trigger.service.name}}",
|
||||||
|
"amount": "{{trigger.amount}}",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"inputUiInfo": {
|
||||||
|
"customizedInputs": {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"schemaVersion": "1",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_deposit_payment_flow() -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Deposit Payment Confirmation Flow
|
||||||
|
|
||||||
|
Trigger: When a deposit payment is received
|
||||||
|
Action: Send payment receipt email with deposit-specific subject
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
"displayName": "Deposit Payment Confirmation",
|
||||||
|
"description": "Send a confirmation when a deposit payment is received",
|
||||||
|
"trigger": {
|
||||||
|
"name": "trigger",
|
||||||
|
"displayName": "Payment Received",
|
||||||
|
"type": "PIECE_TRIGGER",
|
||||||
|
"valid": True,
|
||||||
|
"settings": {
|
||||||
|
"pieceName": "@activepieces/piece-smoothschedule",
|
||||||
|
"pieceVersion": "~0.0.1",
|
||||||
|
"pieceType": "CUSTOM",
|
||||||
|
"triggerName": "payment_received",
|
||||||
|
"input": {
|
||||||
|
"paymentType": "deposit",
|
||||||
|
},
|
||||||
|
"inputUiInfo": {
|
||||||
|
"customizedInputs": {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"nextAction": {
|
||||||
|
"name": "send_deposit_confirmation",
|
||||||
|
"displayName": "Send Deposit Confirmation",
|
||||||
|
"type": "PIECE",
|
||||||
|
"valid": True,
|
||||||
|
"settings": {
|
||||||
|
"pieceName": "@activepieces/piece-smoothschedule",
|
||||||
|
"pieceVersion": "~0.0.1",
|
||||||
|
"pieceType": "CUSTOM",
|
||||||
|
"actionName": "send_email",
|
||||||
|
"input": {
|
||||||
|
"recipientEmail": "{{trigger.customer.email}}",
|
||||||
|
"templateType": EMAIL_TEMPLATES["payment_receipt"],
|
||||||
|
"subjectOverride": "Deposit Received - {{trigger.service.name}}",
|
||||||
|
"context": {
|
||||||
|
"customer_name": "{{trigger.customer.first_name}}",
|
||||||
|
"customer_email": "{{trigger.customer.email}}",
|
||||||
|
"event_title": "{{trigger.event.title}}",
|
||||||
|
"event_date": "{{trigger.event.start_time}}",
|
||||||
|
"service_name": "{{trigger.service.name}}",
|
||||||
|
"amount": "{{trigger.amount}}",
|
||||||
|
"payment_type": "deposit",
|
||||||
|
"remaining_balance": "{{trigger.event.remaining_balance}}",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"inputUiInfo": {
|
||||||
|
"customizedInputs": {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"schemaVersion": "1",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_final_payment_flow() -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Final Payment Confirmation Flow
|
||||||
|
|
||||||
|
Trigger: When a final payment is received
|
||||||
|
Action: Send payment receipt email
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
"displayName": "Final Payment Confirmation",
|
||||||
|
"description": "Send a confirmation when the final payment is received",
|
||||||
|
"trigger": {
|
||||||
|
"name": "trigger",
|
||||||
|
"displayName": "Payment Received",
|
||||||
|
"type": "PIECE_TRIGGER",
|
||||||
|
"valid": True,
|
||||||
|
"settings": {
|
||||||
|
"pieceName": "@activepieces/piece-smoothschedule",
|
||||||
|
"pieceVersion": "~0.0.1",
|
||||||
|
"pieceType": "CUSTOM",
|
||||||
|
"triggerName": "payment_received",
|
||||||
|
"input": {
|
||||||
|
"paymentType": "final",
|
||||||
|
},
|
||||||
|
"inputUiInfo": {
|
||||||
|
"customizedInputs": {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"nextAction": {
|
||||||
|
"name": "send_payment_confirmation",
|
||||||
|
"displayName": "Send Payment Confirmation",
|
||||||
|
"type": "PIECE",
|
||||||
|
"valid": True,
|
||||||
|
"settings": {
|
||||||
|
"pieceName": "@activepieces/piece-smoothschedule",
|
||||||
|
"pieceVersion": "~0.0.1",
|
||||||
|
"pieceType": "CUSTOM",
|
||||||
|
"actionName": "send_email",
|
||||||
|
"input": {
|
||||||
|
"recipientEmail": "{{trigger.customer.email}}",
|
||||||
|
"templateType": EMAIL_TEMPLATES["payment_receipt"],
|
||||||
|
"context": {
|
||||||
|
"customer_name": "{{trigger.customer.first_name}}",
|
||||||
|
"customer_email": "{{trigger.customer.email}}",
|
||||||
|
"event_title": "{{trigger.event.title}}",
|
||||||
|
"service_name": "{{trigger.service.name}}",
|
||||||
|
"amount": "{{trigger.amount}}",
|
||||||
|
"payment_type": "final",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"inputUiInfo": {
|
||||||
|
"customizedInputs": {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"schemaVersion": "1",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Mapping of flow types to their definition functions
|
||||||
|
FLOW_TYPE_DEFINITIONS = {
|
||||||
|
"appointment_confirmation": get_appointment_confirmation_flow,
|
||||||
|
"appointment_reminder": get_appointment_reminder_flow,
|
||||||
|
"thank_you": get_thank_you_flow,
|
||||||
|
"payment_deposit": get_deposit_payment_flow,
|
||||||
|
"payment_final": get_final_payment_flow,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_flow_definition(flow_type: str) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Get the flow definition for a given flow type.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
flow_type: One of the FlowType choices from TenantDefaultFlow model
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Flow definition dict ready for Activepieces API
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If flow_type is not recognized
|
||||||
|
"""
|
||||||
|
if flow_type not in FLOW_TYPE_DEFINITIONS:
|
||||||
|
raise ValueError(f"Unknown flow type: {flow_type}")
|
||||||
|
|
||||||
|
return FLOW_TYPE_DEFINITIONS[flow_type]()
|
||||||
|
|
||||||
|
|
||||||
|
def get_all_flow_definitions() -> Dict[str, Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Get all flow definitions.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict mapping flow_type to its definition
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
flow_type: get_func()
|
||||||
|
for flow_type, get_func in FLOW_TYPE_DEFINITIONS.items()
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# Generated by Django 5.2.8 on 2025-12-21 21:22
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('activepieces', '0001_initial'),
|
||||||
|
('core', '0030_add_sidebar_text_color'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='TenantDefaultFlow',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('flow_type', models.CharField(choices=[('appointment_confirmation', 'Appointment Confirmation Email'), ('appointment_reminder', 'Appointment Reminder'), ('thank_you', 'Thank You Email (After Payment)'), ('payment_deposit', 'Deposit Payment Confirmation'), ('payment_final', 'Final Payment Confirmation')], help_text='Type of default flow', max_length=50)),
|
||||||
|
('activepieces_flow_id', models.CharField(help_text='The Activepieces flow ID', max_length=255)),
|
||||||
|
('is_modified', models.BooleanField(default=False, help_text='Whether user has modified this flow from default')),
|
||||||
|
('default_flow_json', models.JSONField(help_text='The original default flow definition for restore')),
|
||||||
|
('version', models.CharField(default='1.0.0', help_text='Version of the default flow template', max_length=20)),
|
||||||
|
('is_enabled', models.BooleanField(default=True, help_text='Whether this flow is enabled in Activepieces')),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('tenant', models.ForeignKey(help_text='The tenant this default flow belongs to', on_delete=django.db.models.deletion.CASCADE, related_name='default_flows', to='core.tenant')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Tenant Default Flow',
|
||||||
|
'verbose_name_plural': 'Tenant Default Flows',
|
||||||
|
'indexes': [models.Index(fields=['tenant', 'flow_type'], name='activepiece_tenant__a23277_idx'), models.Index(fields=['activepieces_flow_id'], name='activepiece_activep_452ab8_idx')],
|
||||||
|
'unique_together': {('tenant', 'flow_type')},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -74,3 +74,68 @@ class TenantActivepiecesUser(models.Model):
|
|||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.user.email} -> {self.activepieces_user_id}"
|
return f"{self.user.email} -> {self.activepieces_user_id}"
|
||||||
|
|
||||||
|
|
||||||
|
class TenantDefaultFlow(models.Model):
|
||||||
|
"""
|
||||||
|
Tracks default automation flows provisioned for a tenant.
|
||||||
|
|
||||||
|
Used for:
|
||||||
|
1. Identifying which flows are "default" vs user-created
|
||||||
|
2. Enabling restore to default functionality
|
||||||
|
3. Tracking if user has modified the flow
|
||||||
|
"""
|
||||||
|
|
||||||
|
class FlowType(models.TextChoices):
|
||||||
|
APPOINTMENT_CONFIRMATION = "appointment_confirmation", "Appointment Confirmation Email"
|
||||||
|
APPOINTMENT_REMINDER = "appointment_reminder", "Appointment Reminder"
|
||||||
|
THANK_YOU_EMAIL = "thank_you", "Thank You Email (After Payment)"
|
||||||
|
PAYMENT_DEPOSIT = "payment_deposit", "Deposit Payment Confirmation"
|
||||||
|
PAYMENT_FINAL = "payment_final", "Final Payment Confirmation"
|
||||||
|
|
||||||
|
tenant = models.ForeignKey(
|
||||||
|
"core.Tenant",
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="default_flows",
|
||||||
|
help_text="The tenant this default flow belongs to",
|
||||||
|
)
|
||||||
|
flow_type = models.CharField(
|
||||||
|
max_length=50,
|
||||||
|
choices=FlowType.choices,
|
||||||
|
help_text="Type of default flow",
|
||||||
|
)
|
||||||
|
activepieces_flow_id = models.CharField(
|
||||||
|
max_length=255,
|
||||||
|
help_text="The Activepieces flow ID",
|
||||||
|
)
|
||||||
|
is_modified = models.BooleanField(
|
||||||
|
default=False,
|
||||||
|
help_text="Whether user has modified this flow from default",
|
||||||
|
)
|
||||||
|
default_flow_json = models.JSONField(
|
||||||
|
help_text="The original default flow definition for restore",
|
||||||
|
)
|
||||||
|
version = models.CharField(
|
||||||
|
max_length=20,
|
||||||
|
default="1.0.0",
|
||||||
|
help_text="Version of the default flow template",
|
||||||
|
)
|
||||||
|
is_enabled = models.BooleanField(
|
||||||
|
default=True,
|
||||||
|
help_text="Whether this flow is enabled in Activepieces",
|
||||||
|
)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
app_label = "activepieces"
|
||||||
|
verbose_name = "Tenant Default Flow"
|
||||||
|
verbose_name_plural = "Tenant Default Flows"
|
||||||
|
unique_together = ["tenant", "flow_type"]
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=["tenant", "flow_type"]),
|
||||||
|
models.Index(fields=["activepieces_flow_id"]),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.tenant.name} - {self.get_flow_type_display()}"
|
||||||
|
|||||||
@@ -316,6 +316,128 @@ class ActivepiecesClient:
|
|||||||
)
|
)
|
||||||
return result.get("data", [])
|
return result.get("data", [])
|
||||||
|
|
||||||
|
def create_flow(self, project_id: str, token: str, flow_data: dict) -> dict:
|
||||||
|
"""
|
||||||
|
Create a new flow in Activepieces.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
project_id: The Activepieces project ID
|
||||||
|
token: Session token for API calls
|
||||||
|
flow_data: Flow definition including displayName and trigger
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Created flow object with id
|
||||||
|
"""
|
||||||
|
# Create the flow shell
|
||||||
|
result = self._request(
|
||||||
|
"POST",
|
||||||
|
"/api/v1/flows",
|
||||||
|
data={
|
||||||
|
"displayName": flow_data.get("displayName", "Untitled"),
|
||||||
|
"projectId": project_id,
|
||||||
|
},
|
||||||
|
token=token,
|
||||||
|
)
|
||||||
|
|
||||||
|
flow_id = result.get("id")
|
||||||
|
|
||||||
|
# Apply the full flow definition with trigger and actions
|
||||||
|
if flow_id and flow_data.get("trigger"):
|
||||||
|
self.import_flow(
|
||||||
|
flow_id=flow_id,
|
||||||
|
token=token,
|
||||||
|
display_name=flow_data.get("displayName", "Untitled"),
|
||||||
|
trigger=flow_data["trigger"],
|
||||||
|
)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
def import_flow(
|
||||||
|
self, flow_id: str, token: str, display_name: str, trigger: dict
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
Import/update a flow with a trigger and actions structure.
|
||||||
|
|
||||||
|
Uses the IMPORT_FLOW operation to set the complete flow definition.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
flow_id: The Activepieces flow ID
|
||||||
|
token: Session token for API calls
|
||||||
|
display_name: Display name for the flow
|
||||||
|
trigger: The trigger configuration with actions chain
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Updated flow object
|
||||||
|
"""
|
||||||
|
return self._request(
|
||||||
|
"POST",
|
||||||
|
f"/api/v1/flows/{flow_id}",
|
||||||
|
data={
|
||||||
|
"type": "IMPORT_FLOW",
|
||||||
|
"request": {
|
||||||
|
"displayName": display_name,
|
||||||
|
"trigger": trigger,
|
||||||
|
"schemaVersion": "1",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
token=token,
|
||||||
|
)
|
||||||
|
|
||||||
|
def update_flow_status(self, flow_id: str, token: str, enabled: bool) -> dict:
|
||||||
|
"""
|
||||||
|
Enable or disable a flow.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
flow_id: The Activepieces flow ID
|
||||||
|
token: Session token for API calls
|
||||||
|
enabled: True to enable, False to disable
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Updated flow object
|
||||||
|
"""
|
||||||
|
return self._request(
|
||||||
|
"POST",
|
||||||
|
f"/api/v1/flows/{flow_id}",
|
||||||
|
data={
|
||||||
|
"type": "CHANGE_STATUS",
|
||||||
|
"request": {
|
||||||
|
"status": "ENABLED" if enabled else "DISABLED",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
token=token,
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_flow(self, flow_id: str, token: str) -> dict:
|
||||||
|
"""
|
||||||
|
Get a flow by ID.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
flow_id: The Activepieces flow ID
|
||||||
|
token: Session token for API calls
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Flow object
|
||||||
|
"""
|
||||||
|
return self._request(
|
||||||
|
"GET",
|
||||||
|
f"/api/v1/flows/{flow_id}",
|
||||||
|
token=token,
|
||||||
|
)
|
||||||
|
|
||||||
|
def delete_flow(self, flow_id: str, token: str) -> None:
|
||||||
|
"""
|
||||||
|
Delete a flow.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
flow_id: The Activepieces flow ID
|
||||||
|
token: Session token for API calls
|
||||||
|
"""
|
||||||
|
self._request(
|
||||||
|
"DELETE",
|
||||||
|
f"/api/v1/flows/{flow_id}",
|
||||||
|
token=token,
|
||||||
|
)
|
||||||
|
|
||||||
def trigger_webhook(self, webhook_url: str, payload: dict) -> dict:
|
def trigger_webhook(self, webhook_url: str, payload: dict) -> dict:
|
||||||
"""
|
"""
|
||||||
Trigger a flow via its webhook URL.
|
Trigger a flow via its webhook URL.
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ from .views import (
|
|||||||
ActivepiecesFlowsView,
|
ActivepiecesFlowsView,
|
||||||
ActivepiecesHealthView,
|
ActivepiecesHealthView,
|
||||||
ActivepiecesWebhookView,
|
ActivepiecesWebhookView,
|
||||||
|
DefaultFlowsListView,
|
||||||
|
DefaultFlowRestoreView,
|
||||||
|
DefaultFlowsRestoreAllView,
|
||||||
)
|
)
|
||||||
|
|
||||||
app_name = "activepieces"
|
app_name = "activepieces"
|
||||||
@@ -38,4 +41,20 @@ urlpatterns = [
|
|||||||
ActivepiecesHealthView.as_view(),
|
ActivepiecesHealthView.as_view(),
|
||||||
name="health",
|
name="health",
|
||||||
),
|
),
|
||||||
|
# Default flows management
|
||||||
|
path(
|
||||||
|
"default-flows/",
|
||||||
|
DefaultFlowsListView.as_view(),
|
||||||
|
name="default-flows-list",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"default-flows/<str:flow_type>/restore/",
|
||||||
|
DefaultFlowRestoreView.as_view(),
|
||||||
|
name="default-flow-restore",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"default-flows/restore-all/",
|
||||||
|
DefaultFlowsRestoreAllView.as_view(),
|
||||||
|
name="default-flows-restore-all",
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -12,8 +12,9 @@ from rest_framework.views import APIView
|
|||||||
|
|
||||||
from smoothschedule.identity.core.mixins import TenantRequiredAPIView
|
from smoothschedule.identity.core.mixins import TenantRequiredAPIView
|
||||||
|
|
||||||
from .models import TenantActivepiecesProject
|
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
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -194,3 +195,230 @@ class ActivepiecesHealthView(APIView):
|
|||||||
{"status": "unhealthy", "error": str(e)},
|
{"status": "unhealthy", "error": str(e)},
|
||||||
status=status.HTTP_503_SERVICE_UNAVAILABLE,
|
status=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DefaultFlowsListView(TenantRequiredAPIView, APIView):
|
||||||
|
"""
|
||||||
|
List default automation flows for the current tenant.
|
||||||
|
|
||||||
|
GET /api/activepieces/default-flows/
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
{
|
||||||
|
"flows": [
|
||||||
|
{
|
||||||
|
"flow_type": "appointment_confirmation",
|
||||||
|
"display_name": "Appointment Confirmation Email",
|
||||||
|
"activepieces_flow_id": "...",
|
||||||
|
"is_modified": false,
|
||||||
|
"is_enabled": true,
|
||||||
|
"version": "1.0.0"
|
||||||
|
},
|
||||||
|
...
|
||||||
|
]
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
|
def get(self, request):
|
||||||
|
tenant = self.tenant
|
||||||
|
|
||||||
|
flows = TenantDefaultFlow.objects.filter(tenant=tenant)
|
||||||
|
|
||||||
|
flow_data = [
|
||||||
|
{
|
||||||
|
"flow_type": flow.flow_type,
|
||||||
|
"display_name": flow.get_flow_type_display(),
|
||||||
|
"activepieces_flow_id": flow.activepieces_flow_id,
|
||||||
|
"is_modified": flow.is_modified,
|
||||||
|
"is_enabled": flow.is_enabled,
|
||||||
|
"version": flow.version,
|
||||||
|
"created_at": flow.created_at.isoformat(),
|
||||||
|
"updated_at": flow.updated_at.isoformat(),
|
||||||
|
}
|
||||||
|
for flow in flows
|
||||||
|
]
|
||||||
|
|
||||||
|
return self.success_response({"flows": flow_data})
|
||||||
|
|
||||||
|
|
||||||
|
class DefaultFlowRestoreView(TenantRequiredAPIView, APIView):
|
||||||
|
"""
|
||||||
|
Restore a default flow to its original definition.
|
||||||
|
|
||||||
|
POST /api/activepieces/default-flows/<flow_type>/restore/
|
||||||
|
|
||||||
|
This updates the existing flow in Activepieces with the original
|
||||||
|
default definition, preserving the flow ID.
|
||||||
|
|
||||||
|
URL params:
|
||||||
|
flow_type: One of appointment_confirmation, appointment_reminder,
|
||||||
|
thank_you, payment_deposit, payment_final
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"flow_type": "appointment_confirmation",
|
||||||
|
"message": "Flow restored to default"
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
|
def post(self, request, flow_type):
|
||||||
|
tenant = self.tenant
|
||||||
|
|
||||||
|
# Validate flow type
|
||||||
|
valid_types = [choice[0] for choice in TenantDefaultFlow.FlowType.choices]
|
||||||
|
if flow_type not in valid_types:
|
||||||
|
return self.error_response(
|
||||||
|
f"Invalid flow type. Must be one of: {', '.join(valid_types)}",
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get the default flow record
|
||||||
|
try:
|
||||||
|
default_flow = TenantDefaultFlow.objects.get(
|
||||||
|
tenant=tenant, flow_type=flow_type
|
||||||
|
)
|
||||||
|
except TenantDefaultFlow.DoesNotExist:
|
||||||
|
return self.error_response(
|
||||||
|
f"Default flow '{flow_type}' not found for this tenant",
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
)
|
||||||
|
|
||||||
|
client = get_activepieces_client()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Get session for this tenant
|
||||||
|
session_data = client.get_embed_session(tenant)
|
||||||
|
token = session_data.get("token")
|
||||||
|
|
||||||
|
if not token:
|
||||||
|
return self.error_response(
|
||||||
|
"Failed to get Activepieces session",
|
||||||
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 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"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update the Django record
|
||||||
|
default_flow.is_modified = False
|
||||||
|
default_flow.default_flow_json = flow_def
|
||||||
|
default_flow.version = FLOW_VERSION
|
||||||
|
default_flow.save()
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Restored default flow {flow_type} for tenant {tenant.schema_name}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return self.success_response({
|
||||||
|
"success": True,
|
||||||
|
"flow_type": flow_type,
|
||||||
|
"message": "Flow restored to default",
|
||||||
|
})
|
||||||
|
|
||||||
|
except ActivepiecesError as e:
|
||||||
|
logger.error(f"Failed to restore flow: {e}")
|
||||||
|
return self.error_response(
|
||||||
|
"Failed to restore flow in Activepieces",
|
||||||
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DefaultFlowsRestoreAllView(TenantRequiredAPIView, APIView):
|
||||||
|
"""
|
||||||
|
Restore all default flows to their original definitions.
|
||||||
|
|
||||||
|
POST /api/activepieces/default-flows/restore-all/
|
||||||
|
|
||||||
|
This restores all default flows for the tenant, preserving flow IDs.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"restored": ["appointment_confirmation", "appointment_reminder", ...],
|
||||||
|
"failed": [],
|
||||||
|
"message": "Restored 5 flows"
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
|
def post(self, request):
|
||||||
|
tenant = self.tenant
|
||||||
|
|
||||||
|
client = get_activepieces_client()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Get session for this tenant
|
||||||
|
session_data = client.get_embed_session(tenant)
|
||||||
|
token = session_data.get("token")
|
||||||
|
|
||||||
|
if not token:
|
||||||
|
return self.error_response(
|
||||||
|
"Failed to get Activepieces session",
|
||||||
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
|
)
|
||||||
|
|
||||||
|
restored = []
|
||||||
|
failed = []
|
||||||
|
|
||||||
|
# Get all default flows for this tenant
|
||||||
|
default_flows = TenantDefaultFlow.objects.filter(tenant=tenant)
|
||||||
|
|
||||||
|
for default_flow in default_flows:
|
||||||
|
try:
|
||||||
|
# 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"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update the Django record
|
||||||
|
default_flow.is_modified = False
|
||||||
|
default_flow.default_flow_json = flow_def
|
||||||
|
default_flow.version = FLOW_VERSION
|
||||||
|
default_flow.save()
|
||||||
|
|
||||||
|
restored.append(default_flow.flow_type)
|
||||||
|
logger.info(
|
||||||
|
f"Restored default flow {default_flow.flow_type} for tenant {tenant.schema_name}"
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
failed.append(default_flow.flow_type)
|
||||||
|
logger.error(
|
||||||
|
f"Failed to restore flow {default_flow.flow_type}: {e}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return self.success_response({
|
||||||
|
"success": len(failed) == 0,
|
||||||
|
"restored": restored,
|
||||||
|
"failed": failed,
|
||||||
|
"message": f"Restored {len(restored)} flows" + (
|
||||||
|
f", {len(failed)} failed" if failed else ""
|
||||||
|
),
|
||||||
|
})
|
||||||
|
|
||||||
|
except ActivepiecesError as e:
|
||||||
|
logger.error(f"Failed to restore flows: {e}")
|
||||||
|
return self.error_response(
|
||||||
|
"Failed to restore flows in Activepieces",
|
||||||
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
|
)
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ from .views import (
|
|||||||
WebhookViewSet,
|
WebhookViewSet,
|
||||||
EmailTemplateListView,
|
EmailTemplateListView,
|
||||||
SendEmailView,
|
SendEmailView,
|
||||||
|
PaymentListView,
|
||||||
|
UpcomingEventsView,
|
||||||
)
|
)
|
||||||
|
|
||||||
app_name = 'public_api'
|
app_name = 'public_api'
|
||||||
@@ -154,6 +156,12 @@ All errors follow this format:
|
|||||||
path('emails/templates/', EmailTemplateListView.as_view(), name='email-templates'),
|
path('emails/templates/', EmailTemplateListView.as_view(), name='email-templates'),
|
||||||
path('emails/send/', SendEmailView.as_view(), name='send-email'),
|
path('emails/send/', SendEmailView.as_view(), name='send-email'),
|
||||||
|
|
||||||
|
# Payment Endpoints (for Activepieces triggers)
|
||||||
|
path('payments/', PaymentListView.as_view(), name='payments'),
|
||||||
|
|
||||||
|
# Events Endpoints (for Activepieces triggers)
|
||||||
|
path('events/upcoming/', UpcomingEventsView.as_view(), name='upcoming-events'),
|
||||||
|
|
||||||
# ViewSet routes
|
# ViewSet routes
|
||||||
path('', include(router.urls)),
|
path('', include(router.urls)),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1900,6 +1900,326 @@ class WebhookViewSet(PublicAPIViewMixin, viewsets.ViewSet):
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Payment Endpoints (for Activepieces triggers)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
@extend_schema(
|
||||||
|
summary="List recent payments",
|
||||||
|
description=(
|
||||||
|
"Get recent payment transactions for polling triggers (Activepieces integration). "
|
||||||
|
"Returns completed payments with event and customer context."
|
||||||
|
),
|
||||||
|
parameters=[
|
||||||
|
OpenApiParameter(
|
||||||
|
name='created_at__gt',
|
||||||
|
type=OpenApiTypes.DATETIME,
|
||||||
|
location=OpenApiParameter.QUERY,
|
||||||
|
description="Only return payments created after this timestamp (ISO format)",
|
||||||
|
required=False,
|
||||||
|
),
|
||||||
|
OpenApiParameter(
|
||||||
|
name='type',
|
||||||
|
type=str,
|
||||||
|
location=OpenApiParameter.QUERY,
|
||||||
|
description="Filter by payment type: 'deposit' or 'final'",
|
||||||
|
required=False,
|
||||||
|
),
|
||||||
|
OpenApiParameter(
|
||||||
|
name='limit',
|
||||||
|
type=int,
|
||||||
|
location=OpenApiParameter.QUERY,
|
||||||
|
description="Maximum number of payments to return (default: 100, max: 500)",
|
||||||
|
required=False,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
responses={200: OpenApiResponse(description="List of recent payments with event data")},
|
||||||
|
tags=['Payments'],
|
||||||
|
)
|
||||||
|
class PaymentListView(PublicAPIViewMixin, APIView):
|
||||||
|
"""
|
||||||
|
List recent payments for Activepieces polling triggers.
|
||||||
|
|
||||||
|
Returns completed payments (status=SUCCEEDED) with full event and customer context.
|
||||||
|
Use created_at__gt parameter for polling new payments since last check.
|
||||||
|
|
||||||
|
Payment types:
|
||||||
|
- 'deposit': Payments where the event still has remaining balance
|
||||||
|
- 'final': Payments where the event is fully paid
|
||||||
|
|
||||||
|
**Required scope:** `bookings:read`
|
||||||
|
"""
|
||||||
|
permission_classes = [HasAPIToken, CanReadBookings]
|
||||||
|
|
||||||
|
def get(self, request):
|
||||||
|
from django.utils.dateparse import parse_datetime
|
||||||
|
from smoothschedule.commerce.payments.models import TransactionLink
|
||||||
|
from smoothschedule.scheduling.schedule.models import Event
|
||||||
|
|
||||||
|
tenant = self.get_tenant()
|
||||||
|
if not tenant:
|
||||||
|
return Response(
|
||||||
|
{'error': 'not_found', 'message': 'Business not found'},
|
||||||
|
status=status.HTTP_404_NOT_FOUND
|
||||||
|
)
|
||||||
|
|
||||||
|
with schema_context(tenant.schema_name):
|
||||||
|
# Only get successful payments
|
||||||
|
queryset = TransactionLink.objects.filter(
|
||||||
|
status=TransactionLink.Status.SUCCEEDED
|
||||||
|
).select_related('event', 'event__service').order_by('-completed_at', '-created_at')
|
||||||
|
|
||||||
|
# Filter by time
|
||||||
|
created_after = request.query_params.get('created_at__gt')
|
||||||
|
if created_after:
|
||||||
|
dt = parse_datetime(created_after)
|
||||||
|
if dt:
|
||||||
|
queryset = queryset.filter(completed_at__gt=dt)
|
||||||
|
|
||||||
|
# Filter by payment type
|
||||||
|
payment_type = request.query_params.get('type')
|
||||||
|
# We'll filter by type after fetching (requires checking event data)
|
||||||
|
|
||||||
|
# Limit results
|
||||||
|
try:
|
||||||
|
limit = min(int(request.query_params.get('limit', 100)), 500)
|
||||||
|
except ValueError:
|
||||||
|
limit = 100
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for tx in queryset[:limit * 2]: # Fetch extra for type filtering
|
||||||
|
if len(results) >= limit:
|
||||||
|
break
|
||||||
|
|
||||||
|
event = tx.event
|
||||||
|
if not event:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Determine payment type based on event state
|
||||||
|
is_deposit = event.deposit_transaction_id == tx.payment_intent_id
|
||||||
|
is_final = event.final_charge_transaction_id == tx.payment_intent_id
|
||||||
|
|
||||||
|
# Infer type if not explicitly set
|
||||||
|
if not is_deposit and not is_final:
|
||||||
|
# If deposit amount matches, it's likely a deposit
|
||||||
|
if event.deposit_amount and tx.amount == event.deposit_amount:
|
||||||
|
is_deposit = True
|
||||||
|
else:
|
||||||
|
is_final = True
|
||||||
|
|
||||||
|
tx_type = 'deposit' if is_deposit else 'final'
|
||||||
|
|
||||||
|
# Filter by type if requested
|
||||||
|
if payment_type and tx_type != payment_type:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Get customer info from participants
|
||||||
|
customer = None
|
||||||
|
participants = list(event.participants.all())
|
||||||
|
for p in participants:
|
||||||
|
obj = p.content_object
|
||||||
|
if p.role == 'CUSTOMER' and obj:
|
||||||
|
customer = {
|
||||||
|
'id': getattr(obj, 'id', None),
|
||||||
|
'first_name': getattr(obj, 'first_name', ''),
|
||||||
|
'last_name': getattr(obj, 'last_name', ''),
|
||||||
|
'email': getattr(obj, 'email', ''),
|
||||||
|
'phone': getattr(obj, 'phone', ''),
|
||||||
|
}
|
||||||
|
break
|
||||||
|
|
||||||
|
# Get service info
|
||||||
|
service_data = None
|
||||||
|
if event.service:
|
||||||
|
service_data = {
|
||||||
|
'id': event.service.id,
|
||||||
|
'name': event.service.name,
|
||||||
|
'price': str(event.service.price),
|
||||||
|
}
|
||||||
|
|
||||||
|
results.append({
|
||||||
|
'id': tx.id,
|
||||||
|
'payment_intent_id': tx.payment_intent_id,
|
||||||
|
'amount': str(tx.amount),
|
||||||
|
'currency': tx.currency,
|
||||||
|
'type': tx_type,
|
||||||
|
'status': tx.status,
|
||||||
|
'created_at': tx.created_at.isoformat() if tx.created_at else None,
|
||||||
|
'completed_at': tx.completed_at.isoformat() if tx.completed_at else None,
|
||||||
|
'event': {
|
||||||
|
'id': event.id,
|
||||||
|
'title': event.title,
|
||||||
|
'start_time': event.start_time.isoformat() if event.start_time else None,
|
||||||
|
'end_time': event.end_time.isoformat() if event.end_time else None,
|
||||||
|
'status': event.status,
|
||||||
|
'deposit_amount': str(event.deposit_amount) if event.deposit_amount else None,
|
||||||
|
'final_price': str(event.final_price) if event.final_price else None,
|
||||||
|
'remaining_balance': str(event.remaining_balance) if event.remaining_balance else None,
|
||||||
|
},
|
||||||
|
'service': service_data,
|
||||||
|
'customer': customer,
|
||||||
|
})
|
||||||
|
|
||||||
|
return Response(results)
|
||||||
|
|
||||||
|
|
||||||
|
@extend_schema(
|
||||||
|
summary="List upcoming events",
|
||||||
|
description=(
|
||||||
|
"Get events starting within the specified time window for reminder triggers. "
|
||||||
|
"Used by Activepieces to trigger appointment reminder flows."
|
||||||
|
),
|
||||||
|
parameters=[
|
||||||
|
OpenApiParameter(
|
||||||
|
name='hours_ahead',
|
||||||
|
type=int,
|
||||||
|
location=OpenApiParameter.QUERY,
|
||||||
|
description="Return events starting within this many hours (default: 24, max: 168)",
|
||||||
|
required=False,
|
||||||
|
),
|
||||||
|
OpenApiParameter(
|
||||||
|
name='status',
|
||||||
|
type=str,
|
||||||
|
location=OpenApiParameter.QUERY,
|
||||||
|
description="Filter by status (default: SCHEDULED)",
|
||||||
|
required=False,
|
||||||
|
),
|
||||||
|
OpenApiParameter(
|
||||||
|
name='limit',
|
||||||
|
type=int,
|
||||||
|
location=OpenApiParameter.QUERY,
|
||||||
|
description="Maximum number of events to return (default: 100, max: 500)",
|
||||||
|
required=False,
|
||||||
|
),
|
||||||
|
OpenApiParameter(
|
||||||
|
name='last_checked_at',
|
||||||
|
type=OpenApiTypes.DATETIME,
|
||||||
|
location=OpenApiParameter.QUERY,
|
||||||
|
description="Return only events that haven't been fetched since this time (for deduplication)",
|
||||||
|
required=False,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
responses={200: OpenApiResponse(description="List of upcoming events with service and customer data")},
|
||||||
|
tags=['Events'],
|
||||||
|
)
|
||||||
|
class UpcomingEventsView(PublicAPIViewMixin, APIView):
|
||||||
|
"""
|
||||||
|
List upcoming events for reminder automation triggers.
|
||||||
|
|
||||||
|
Returns events starting within the specified time window with full
|
||||||
|
service and customer context. Events are ordered by start time.
|
||||||
|
|
||||||
|
The hours_ahead parameter determines how far in the future to look.
|
||||||
|
Each service has its own reminder_hours_before setting which should
|
||||||
|
match the polling frequency.
|
||||||
|
|
||||||
|
**Required scope:** `bookings:read`
|
||||||
|
"""
|
||||||
|
permission_classes = [HasAPIToken, CanReadBookings]
|
||||||
|
|
||||||
|
def get(self, request):
|
||||||
|
from django.utils import timezone as tz
|
||||||
|
from django.utils.dateparse import parse_datetime
|
||||||
|
from datetime import timedelta
|
||||||
|
from smoothschedule.scheduling.schedule.models import Event
|
||||||
|
|
||||||
|
tenant = self.get_tenant()
|
||||||
|
if not tenant:
|
||||||
|
return Response(
|
||||||
|
{'error': 'not_found', 'message': 'Business not found'},
|
||||||
|
status=status.HTTP_404_NOT_FOUND
|
||||||
|
)
|
||||||
|
|
||||||
|
# Parse parameters
|
||||||
|
try:
|
||||||
|
hours_ahead = int(request.query_params.get('hours_ahead', 24))
|
||||||
|
hours_ahead = max(1, min(hours_ahead, 168)) # Clamp 1-168 hours
|
||||||
|
except ValueError:
|
||||||
|
hours_ahead = 24
|
||||||
|
|
||||||
|
try:
|
||||||
|
limit = int(request.query_params.get('limit', 100))
|
||||||
|
limit = max(1, min(limit, 500))
|
||||||
|
except ValueError:
|
||||||
|
limit = 100
|
||||||
|
|
||||||
|
status_filter = request.query_params.get('status', 'SCHEDULED')
|
||||||
|
last_checked_at = request.query_params.get('last_checked_at')
|
||||||
|
|
||||||
|
now = tz.now()
|
||||||
|
window_end = now + timedelta(hours=hours_ahead)
|
||||||
|
|
||||||
|
with schema_context(tenant.schema_name):
|
||||||
|
queryset = Event.objects.filter(
|
||||||
|
start_time__gte=now,
|
||||||
|
start_time__lte=window_end,
|
||||||
|
status=status_filter.upper(),
|
||||||
|
).select_related('service').order_by('start_time')
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for event in queryset[:limit]:
|
||||||
|
# Get participants
|
||||||
|
participants = list(event.participants.all())
|
||||||
|
|
||||||
|
customer = None
|
||||||
|
resources = []
|
||||||
|
for p in participants:
|
||||||
|
obj = p.content_object
|
||||||
|
if p.role == 'CUSTOMER' and obj:
|
||||||
|
customer = {
|
||||||
|
'id': getattr(obj, 'id', None),
|
||||||
|
'first_name': getattr(obj, 'first_name', ''),
|
||||||
|
'last_name': getattr(obj, 'last_name', ''),
|
||||||
|
'email': getattr(obj, 'email', ''),
|
||||||
|
'phone': getattr(obj, 'phone', ''),
|
||||||
|
}
|
||||||
|
elif p.role == 'RESOURCE' and obj:
|
||||||
|
resources.append({
|
||||||
|
'id': getattr(obj, 'id', None),
|
||||||
|
'name': getattr(obj, 'name', ''),
|
||||||
|
})
|
||||||
|
|
||||||
|
# Service and reminder info
|
||||||
|
service_data = None
|
||||||
|
reminder_hours_before = 24 # Default
|
||||||
|
if event.service:
|
||||||
|
service_data = {
|
||||||
|
'id': event.service.id,
|
||||||
|
'name': event.service.name,
|
||||||
|
'duration': event.service.duration,
|
||||||
|
'price': str(event.service.price),
|
||||||
|
'reminder_enabled': event.service.reminder_enabled,
|
||||||
|
'reminder_hours_before': event.service.reminder_hours_before,
|
||||||
|
}
|
||||||
|
reminder_hours_before = event.service.reminder_hours_before
|
||||||
|
|
||||||
|
# Calculate hours until event
|
||||||
|
hours_until = (event.start_time - now).total_seconds() / 3600
|
||||||
|
|
||||||
|
results.append({
|
||||||
|
'id': event.id,
|
||||||
|
'title': event.title,
|
||||||
|
'start_time': event.start_time.isoformat(),
|
||||||
|
'end_time': event.end_time.isoformat() if event.end_time else None,
|
||||||
|
'status': event.status,
|
||||||
|
'hours_until_start': round(hours_until, 2),
|
||||||
|
'reminder_hours_before': reminder_hours_before,
|
||||||
|
'should_send_reminder': hours_until <= reminder_hours_before,
|
||||||
|
'service': service_data,
|
||||||
|
'customer': customer,
|
||||||
|
'resources': resources,
|
||||||
|
'notes': getattr(event, 'notes', None),
|
||||||
|
'location': {
|
||||||
|
'id': event.location.id,
|
||||||
|
'name': event.location.name,
|
||||||
|
'address': event.location.address_line1,
|
||||||
|
} if event.location else None,
|
||||||
|
'created_at': event.created_at.isoformat() if event.created_at else None,
|
||||||
|
})
|
||||||
|
|
||||||
|
return Response(results)
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Email Endpoints
|
# Email Endpoints
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
Reference in New Issue
Block a user