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:
poduck
2025-12-21 16:34:29 -05:00
parent 8564b1deba
commit 99f8271003
15 changed files with 1804 additions and 3 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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",
),
] ]

View File

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

View File

@@ -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)),
] ]

View File

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