diff --git a/activepieces-fork/packages/pieces/community/smoothschedule/src/index.ts b/activepieces-fork/packages/pieces/community/smoothschedule/src/index.ts index 5b653091..0b18cf30 100644 --- a/activepieces-fork/packages/pieces/community/smoothschedule/src/index.ts +++ b/activepieces-fork/packages/pieces/community/smoothschedule/src/index.ts @@ -5,9 +5,10 @@ import { createEventAction, findEventsAction, updateEventAction, cancelEventActi import { listResourcesAction } from './lib/actions/list-resources'; import { listServicesAction } from './lib/actions/list-services'; import { listInactiveCustomersAction } from './lib/actions/list-inactive-customers'; +import { listCustomersAction } from './lib/actions/list-customers'; import { sendEmailAction } from './lib/actions/send-email'; import { listEmailTemplatesAction } from './lib/actions/list-email-templates'; -import { eventCreatedTrigger, eventUpdatedTrigger, eventCancelledTrigger, eventStatusChangedTrigger } from './lib/triggers'; +import { eventCreatedTrigger, eventUpdatedTrigger, eventCancelledTrigger, eventStatusChangedTrigger, paymentReceivedTrigger, upcomingEventsTrigger } from './lib/triggers'; import { API_URL } from './lib/common'; /** @@ -75,6 +76,7 @@ export const smoothSchedule = createPiece({ listResourcesAction, listServicesAction, listInactiveCustomersAction, + listCustomersAction, sendEmailAction, listEmailTemplatesAction, createCustomApiCallAction({ @@ -89,5 +91,5 @@ export const smoothSchedule = createPiece({ }, }), ], - triggers: [eventCreatedTrigger, eventUpdatedTrigger, eventCancelledTrigger, eventStatusChangedTrigger], + triggers: [eventCreatedTrigger, eventUpdatedTrigger, eventCancelledTrigger, eventStatusChangedTrigger, paymentReceivedTrigger, upcomingEventsTrigger], }); diff --git a/activepieces-fork/packages/pieces/community/smoothschedule/src/lib/actions/index.ts b/activepieces-fork/packages/pieces/community/smoothschedule/src/lib/actions/index.ts index 9fe817ab..5f5ef90e 100644 --- a/activepieces-fork/packages/pieces/community/smoothschedule/src/lib/actions/index.ts +++ b/activepieces-fork/packages/pieces/community/smoothschedule/src/lib/actions/index.ts @@ -5,3 +5,4 @@ export * from './find-events'; export * from './list-resources'; export * from './list-services'; export * from './list-inactive-customers'; +export * from './list-customers'; diff --git a/activepieces-fork/packages/pieces/community/smoothschedule/src/lib/actions/list-customers.ts b/activepieces-fork/packages/pieces/community/smoothschedule/src/lib/actions/list-customers.ts new file mode 100644 index 00000000..26a8afa8 --- /dev/null +++ b/activepieces-fork/packages/pieces/community/smoothschedule/src/lib/actions/list-customers.ts @@ -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 { + 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 = {}; + + 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>( + 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, + }; + }, +}); diff --git a/activepieces-fork/packages/pieces/community/smoothschedule/src/lib/triggers/index.ts b/activepieces-fork/packages/pieces/community/smoothschedule/src/lib/triggers/index.ts index f5acd2cc..aa925111 100644 --- a/activepieces-fork/packages/pieces/community/smoothschedule/src/lib/triggers/index.ts +++ b/activepieces-fork/packages/pieces/community/smoothschedule/src/lib/triggers/index.ts @@ -2,3 +2,5 @@ export * from './event-created'; export * from './event-updated'; export * from './event-cancelled'; export * from './event-status-changed'; +export * from './payment-received'; +export * from './upcoming-events'; diff --git a/activepieces-fork/packages/pieces/community/smoothschedule/src/lib/triggers/payment-received.ts b/activepieces-fork/packages/pieces/community/smoothschedule/src/lib/triggers/payment-received.ts new file mode 100644 index 00000000..d94cce2e --- /dev/null +++ b/activepieces-fork/packages/pieces/community/smoothschedule/src/lib/triggers/payment-received.ts @@ -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 = { + limit: '5', + }; + + if (paymentType && paymentType !== 'all') { + queryParams['type'] = paymentType; + } + + const payments = await makeRequest( + 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(TRIGGER_KEY) || new Date(0).toISOString(); + + const queryParams: Record = { + 'created_at__gt': lastCheck, + limit: '100', + }; + + if (paymentType && paymentType !== 'all') { + queryParams['type'] = paymentType; + } + + const payments = await makeRequest( + 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', + }, + }, +}); diff --git a/activepieces-fork/packages/pieces/community/smoothschedule/src/lib/triggers/upcoming-events.ts b/activepieces-fork/packages/pieces/community/smoothschedule/src/lib/triggers/upcoming-events.ts new file mode 100644 index 00000000..1aa3701e --- /dev/null +++ b/activepieces-fork/packages/pieces/community/smoothschedule/src/lib/triggers/upcoming-events.ts @@ -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 = { + hours_ahead: String(hoursAhead || 24), + limit: '5', + }; + + const events = await makeRequest( + 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(TRIGGER_KEY_PREFIX) || '[]'; + let processedIds: number[] = []; + try { + processedIds = JSON.parse(processedIdsJson); + } catch { + processedIds = []; + } + + const queryParams: Record = { + hours_ahead: String(hoursAhead || 24), + limit: '100', + }; + + const events = await makeRequest( + 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', + }, +}); diff --git a/smoothschedule/smoothschedule/identity/core/signals.py b/smoothschedule/smoothschedule/identity/core/signals.py index ed41638b..5dbb7663 100644 --- a/smoothschedule/smoothschedule/identity/core/signals.py +++ b/smoothschedule/smoothschedule/identity/core/signals.py @@ -247,3 +247,162 @@ def provision_activepieces_on_tenant_create(sender, instance, created, **kwargs) tenant_id = instance.id # Use a delay to ensure all other tenant setup is complete 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)) diff --git a/smoothschedule/smoothschedule/integrations/activepieces/default_flows.py b/smoothschedule/smoothschedule/integrations/activepieces/default_flows.py new file mode 100644 index 00000000..8694aad5 --- /dev/null +++ b/smoothschedule/smoothschedule/integrations/activepieces/default_flows.py @@ -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() + } diff --git a/smoothschedule/smoothschedule/integrations/activepieces/migrations/0002_add_tenant_default_flow.py b/smoothschedule/smoothschedule/integrations/activepieces/migrations/0002_add_tenant_default_flow.py new file mode 100644 index 00000000..71771df8 --- /dev/null +++ b/smoothschedule/smoothschedule/integrations/activepieces/migrations/0002_add_tenant_default_flow.py @@ -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')}, + }, + ), + ] diff --git a/smoothschedule/smoothschedule/integrations/activepieces/models.py b/smoothschedule/smoothschedule/integrations/activepieces/models.py index baeb7b7f..be3289be 100644 --- a/smoothschedule/smoothschedule/integrations/activepieces/models.py +++ b/smoothschedule/smoothschedule/integrations/activepieces/models.py @@ -74,3 +74,68 @@ class TenantActivepiecesUser(models.Model): def __str__(self): 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()}" diff --git a/smoothschedule/smoothschedule/integrations/activepieces/services.py b/smoothschedule/smoothschedule/integrations/activepieces/services.py index eb37bff5..770d91c3 100644 --- a/smoothschedule/smoothschedule/integrations/activepieces/services.py +++ b/smoothschedule/smoothschedule/integrations/activepieces/services.py @@ -316,6 +316,128 @@ class ActivepiecesClient: ) 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: """ Trigger a flow via its webhook URL. diff --git a/smoothschedule/smoothschedule/integrations/activepieces/urls.py b/smoothschedule/smoothschedule/integrations/activepieces/urls.py index 2adc4d0e..679d32a0 100644 --- a/smoothschedule/smoothschedule/integrations/activepieces/urls.py +++ b/smoothschedule/smoothschedule/integrations/activepieces/urls.py @@ -9,6 +9,9 @@ from .views import ( ActivepiecesFlowsView, ActivepiecesHealthView, ActivepiecesWebhookView, + DefaultFlowsListView, + DefaultFlowRestoreView, + DefaultFlowsRestoreAllView, ) app_name = "activepieces" @@ -38,4 +41,20 @@ urlpatterns = [ ActivepiecesHealthView.as_view(), name="health", ), + # Default flows management + path( + "default-flows/", + DefaultFlowsListView.as_view(), + name="default-flows-list", + ), + path( + "default-flows//restore/", + DefaultFlowRestoreView.as_view(), + name="default-flow-restore", + ), + path( + "default-flows/restore-all/", + DefaultFlowsRestoreAllView.as_view(), + name="default-flows-restore-all", + ), ] diff --git a/smoothschedule/smoothschedule/integrations/activepieces/views.py b/smoothschedule/smoothschedule/integrations/activepieces/views.py index 277a835c..d584214b 100644 --- a/smoothschedule/smoothschedule/integrations/activepieces/views.py +++ b/smoothschedule/smoothschedule/integrations/activepieces/views.py @@ -12,8 +12,9 @@ from rest_framework.views import APIView from smoothschedule.identity.core.mixins import TenantRequiredAPIView -from .models import TenantActivepiecesProject +from .models import TenantActivepiecesProject, TenantDefaultFlow from .services import ActivepiecesError, get_activepieces_client +from .default_flows import get_flow_definition, FLOW_VERSION logger = logging.getLogger(__name__) @@ -194,3 +195,230 @@ class ActivepiecesHealthView(APIView): {"status": "unhealthy", "error": str(e)}, 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//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, + ) diff --git a/smoothschedule/smoothschedule/platform/api/urls.py b/smoothschedule/smoothschedule/platform/api/urls.py index 57f5ec6e..3f750a94 100644 --- a/smoothschedule/smoothschedule/platform/api/urls.py +++ b/smoothschedule/smoothschedule/platform/api/urls.py @@ -29,6 +29,8 @@ from .views import ( WebhookViewSet, EmailTemplateListView, SendEmailView, + PaymentListView, + UpcomingEventsView, ) app_name = 'public_api' @@ -154,6 +156,12 @@ All errors follow this format: path('emails/templates/', EmailTemplateListView.as_view(), name='email-templates'), 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 path('', include(router.urls)), ] diff --git a/smoothschedule/smoothschedule/platform/api/views.py b/smoothschedule/smoothschedule/platform/api/views.py index b3826826..0da1d0e4 100644 --- a/smoothschedule/smoothschedule/platform/api/views.py +++ b/smoothschedule/smoothschedule/platform/api/views.py @@ -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 # =============================================================================