diff --git a/apps/sim/app/api/forms/[formId]/route.ts b/apps/sim/app/api/forms/[formId]/route.ts new file mode 100644 index 0000000000..1da210222c --- /dev/null +++ b/apps/sim/app/api/forms/[formId]/route.ts @@ -0,0 +1,51 @@ +import { db } from '@sim/db' +import { workflow, workflowForm } from '@sim/db/schema' +import { and, eq } from 'drizzle-orm' +import { type NextRequest, NextResponse } from 'next/server' + +export const dynamic = 'force-dynamic' + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ formId: string }> } +) { + try { + const formId = (await params).formId + + const forms = await db + .select({ + form: workflowForm, + workflow: workflow, + }) + .from(workflowForm) + .innerJoin(workflow, eq(workflowForm.workflowId, workflow.id)) + .where(and(eq(workflowForm.path, formId), eq(workflowForm.isActive, true))) + .limit(1) + + if (forms.length === 0) { + return new NextResponse('Form not found', { status: 404 }) + } + + const form = forms[0].form + const workflowData = forms[0].workflow + + return NextResponse.json({ + id: form.id, + title: form.title, + description: form.description, + formConfig: form.formConfig, + styling: form.styling, + settings: form.settings, + workflow: { + id: workflowData.id, + name: workflowData.name, + color: workflowData.color, + }, + }) + } catch (error: any) { + console.error('Error fetching form:', error) + return new NextResponse(`Internal Server Error: ${error.message}`, { + status: 500, + }) + } +} diff --git a/apps/sim/app/api/forms/[formId]/submit/route.ts b/apps/sim/app/api/forms/[formId]/submit/route.ts new file mode 100644 index 0000000000..5a397cfef9 --- /dev/null +++ b/apps/sim/app/api/forms/[formId]/submit/route.ts @@ -0,0 +1,228 @@ +import { db } from '@sim/db' +import { workflow, workflowForm } from '@sim/db/schema' +import { tasks } from '@trigger.dev/sdk' +import { and, eq } from 'drizzle-orm' +import { type NextRequest, NextResponse } from 'next/server' +import { checkServerSideUsageLimits } from '@/lib/billing' +import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription' +import { quickValidateEmail } from '@/lib/email/validation' +import { env, isTruthy } from '@/lib/env' +import { IdempotencyService, webhookIdempotency } from '@/lib/idempotency/service' +import { createLogger } from '@/lib/logs/console/logger' +import { generateRequestId } from '@/lib/utils' +import { executeFormSubmissionJob } from '@/background/form-execution' +import { RateLimiter } from '@/services/queue' + +const logger = createLogger('FormSubmissionAPI') + +export const dynamic = 'force-dynamic' +export const maxDuration = 300 +export const runtime = 'nodejs' + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ formId: string }> } +) { + const requestId = generateRequestId() + let foundWorkflow: any = null + let foundForm: any = null + + let body: any + try { + body = await request.json() + + if (!body || Object.keys(body).length === 0) { + logger.warn(`[${requestId}] Rejecting empty form submission`) + return new NextResponse('Empty form submission', { status: 400 }) + } + } catch (parseError) { + logger.error(`[${requestId}] Failed to parse form submission`, { + error: parseError instanceof Error ? parseError.message : String(parseError), + }) + return new NextResponse('Invalid form data', { status: 400 }) + } + + const formId = (await params).formId + logger.info(`[${requestId}] Processing form submission for form: ${formId}`) + + const forms = await db + .select({ + form: workflowForm, + workflow: workflow, + }) + .from(workflowForm) + .innerJoin(workflow, eq(workflowForm.workflowId, workflow.id)) + .where(and(eq(workflowForm.path, formId), eq(workflowForm.isActive, true))) + .limit(1) + + if (forms.length === 0) { + logger.warn(`[${requestId}] No active form found for path: ${formId}`) + return new NextResponse('Form not found', { status: 404 }) + } + + foundForm = forms[0].form + foundWorkflow = forms[0].workflow + + try { + const formConfig = foundForm.formConfig as any + const fields = formConfig.fields || [] + + for (const field of fields) { + if (field.required && (!body[field.name] || body[field.name] === '')) { + logger.warn(`[${requestId}] Missing required field: ${field.name}`) + return NextResponse.json({ error: `Field '${field.label}' is required` }, { status: 400 }) + } + + if (body[field.name] && field.type === 'email') { + const validation = quickValidateEmail(body[field.name]) + if (!validation.isValid) { + return NextResponse.json( + { error: `Field '${field.label}' must be a valid email: ${validation.reason}` }, + { status: 400 } + ) + } + } + } + + logger.debug(`[${requestId}] Form validation passed`) + } catch (validationError) { + logger.error(`[${requestId}] Form validation error:`, validationError) + return new NextResponse('Form validation failed', { status: 400 }) + } + + try { + const userSubscription = await getHighestPrioritySubscription(foundWorkflow.userId) + + logger.info(`[${requestId}] Rate limiting check for user ${foundWorkflow.userId}`, { + userId: foundWorkflow.userId, + subscription: userSubscription, + plan: userSubscription?.plan || 'free', + }) + + const rateLimiter = new RateLimiter() + const rateLimitCheck = await rateLimiter.checkRateLimitWithSubscription( + foundWorkflow.userId, + userSubscription, + 'form', + true + ) + + logger.info(`[${requestId}] Rate limit check result`, { + allowed: rateLimitCheck.allowed, + remaining: rateLimitCheck.remaining, + resetAt: rateLimitCheck.resetAt, + }) + + if (!rateLimitCheck.allowed) { + logger.warn(`[${requestId}] Rate limit exceeded for form user ${foundWorkflow.userId}`, { + remaining: rateLimitCheck.remaining, + resetAt: rateLimitCheck.resetAt, + }) + + return NextResponse.json( + { error: 'Rate limit exceeded. Please try again later.' }, + { status: 429 } + ) + } + + logger.debug(`[${requestId}] Rate limit check passed for form`, { + remaining: rateLimitCheck.remaining, + resetAt: rateLimitCheck.resetAt, + }) + } catch (rateLimitError) { + logger.error(`[${requestId}] Error checking form rate limits:`, rateLimitError) + } + + try { + const usageCheck = await checkServerSideUsageLimits(foundWorkflow.userId) + if (usageCheck.isExceeded) { + logger.warn( + `[${requestId}] User ${foundWorkflow.userId} has exceeded usage limits. Skipping form execution.`, + { + currentUsage: usageCheck.currentUsage, + limit: usageCheck.limit, + workflowId: foundWorkflow.id, + } + ) + + return NextResponse.json( + { error: 'Usage limit exceeded. Please upgrade your plan to continue.' }, + { status: 429 } + ) + } + + logger.debug(`[${requestId}] Usage limit check passed for form`, { + currentUsage: usageCheck.currentUsage, + limit: usageCheck.limit, + }) + } catch (usageError) { + logger.error(`[${requestId}] Error checking form usage limits:`, usageError) + } + + try { + const payload = { + formId: foundForm.id, + workflowId: foundWorkflow.id, + userId: foundWorkflow.userId, + formData: body, + headers: Object.fromEntries(request.headers.entries()), + path: formId, + blockId: foundForm.blockId, + } + + const idempotencyKey = IdempotencyService.createWebhookIdempotencyKey( + foundForm.id, + Object.fromEntries(request.headers.entries()) + ) + + const runOperation = async () => { + const useTrigger = isTruthy(env.TRIGGER_DEV_ENABLED) + + if (useTrigger) { + const handle = await tasks.trigger('form-submission', payload) + logger.info(`[${requestId}] Queued form submission task ${handle.id}`) + return { + method: 'trigger.dev', + taskId: handle.id, + status: 'queued', + } + } + + void executeFormSubmissionJob(payload).catch((error) => { + logger.error(`[${requestId}] Direct form execution failed`, error) + }) + + logger.info(`[${requestId}] Queued direct form execution (Trigger.dev disabled)`) + return { + method: 'direct', + status: 'queued', + } + } + + const result = await webhookIdempotency.executeWithIdempotency( + 'form', + idempotencyKey, + runOperation + ) + + logger.debug(`[${requestId}] Form submission result:`, result) + + const settings = foundForm.settings as any + const successMessage = settings?.successMessage || 'Thank you for your submission!' + const redirectUrl = settings?.redirectUrl + + const response: any = { + success: true, + message: successMessage, + } + + if (redirectUrl) { + response.redirectUrl = redirectUrl + } + + return NextResponse.json(response) + } catch (error: any) { + logger.error(`[${requestId}] Failed to process form submission:`, error) + return NextResponse.json({ error: 'Failed to process form submission' }, { status: 500 }) + } +} diff --git a/apps/sim/app/api/workflows/[id]/forms/route.ts b/apps/sim/app/api/workflows/[id]/forms/route.ts new file mode 100644 index 0000000000..7f6f275145 --- /dev/null +++ b/apps/sim/app/api/workflows/[id]/forms/route.ts @@ -0,0 +1,206 @@ +import { db } from '@sim/db' +import { workflow, workflowForm } from '@sim/db/schema' +import { and, eq } from 'drizzle-orm' +import { type NextRequest, NextResponse } from 'next/server' +import { v4 as uuidv4 } from 'uuid' +import { getSession } from '@/lib/auth' + +export const dynamic = 'force-dynamic' + +/** + * Create or update a workflow form configuration + */ +export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { + try { + const session = await getSession() + if (!session?.user?.id) { + return new NextResponse('Unauthorized', { status: 401 }) + } + + const workflowId = (await params).id + const body = await request.json() + + const { blockId, title, description, formConfig, settings, styling } = body + + const workflowResult = await db + .select() + .from(workflow) + .where(eq(workflow.id, workflowId)) + .limit(1) + + if (workflowResult.length === 0) { + return new NextResponse('Workflow not found', { status: 404 }) + } + + const workflowData = workflowResult[0] + if (workflowData.userId !== session.user.id) { + return new NextResponse('Access denied', { status: 403 }) + } + + const existingForm = await db + .select() + .from(workflowForm) + .where(and(eq(workflowForm.workflowId, workflowId), eq(workflowForm.blockId, blockId))) + .limit(1) + + const formPath = uuidv4() + const formData = { + workflowId, + blockId, + path: formPath, + title, + description, + formConfig, + styling: styling || {}, + settings: settings || {}, + isActive: true, + } + + let result + if (existingForm.length > 0) { + result = await db + .update(workflowForm) + .set({ + ...formData, + path: existingForm[0].path, + updatedAt: new Date(), + }) + .where(eq(workflowForm.id, existingForm[0].id)) + .returning() + + result[0] = { ...result[0], path: existingForm[0].path } + } else { + result = await db + .insert(workflowForm) + .values({ + id: uuidv4(), + ...formData, + }) + .returning() + } + + return NextResponse.json({ + id: result[0].id, + path: result[0].path, + title: result[0].title, + formConfig: result[0].formConfig, + }) + } catch (error: any) { + console.error('Error creating/updating form:', error) + return new NextResponse(`Internal Server Error: ${error.message}`, { + status: 500, + }) + } +} + +/** + * Get all forms for a workflow + */ +export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { + try { + const session = await getSession() + if (!session?.user?.id) { + return new NextResponse('Unauthorized', { status: 401 }) + } + + const workflowId = (await params).id + const url = new URL(request.url) + const blockId = url.searchParams.get('blockId') + + const workflowResult = await db + .select() + .from(workflow) + .where(eq(workflow.id, workflowId)) + .limit(1) + + if (workflowResult.length === 0) { + return new NextResponse('Workflow not found', { status: 404 }) + } + + const workflowData = workflowResult[0] + if (workflowData.userId !== session.user.id) { + return new NextResponse('Access denied', { status: 403 }) + } + + const baseCondition = eq(workflowForm.workflowId, workflowId) + const whereConditions = blockId + ? and(baseCondition, eq(workflowForm.blockId, blockId))! + : baseCondition + + const forms = await db.select().from(workflowForm).where(whereConditions) + + return NextResponse.json({ forms }) + } catch (error: any) { + console.error('Error fetching forms:', error) + return new NextResponse(`Internal Server Error: ${error.message}`, { + status: 500, + }) + } +} + +/** + * Update an existing form configuration + */ +export async function PUT(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { + try { + const session = await getSession() + if (!session?.user?.id) { + return new NextResponse('Unauthorized', { status: 401 }) + } + + const workflowId = (await params).id + const body = await request.json() + + const { blockId, title, description, formConfig, settings, styling } = body + + const workflowResult = await db + .select() + .from(workflow) + .where(eq(workflow.id, workflowId)) + .limit(1) + + if (workflowResult.length === 0) { + return new NextResponse('Workflow not found', { status: 404 }) + } + + const workflowData = workflowResult[0] + if (workflowData.userId !== session.user.id) { + return new NextResponse('Access denied', { status: 403 }) + } + + const existingForm = await db + .select() + .from(workflowForm) + .where(and(eq(workflowForm.workflowId, workflowId), eq(workflowForm.blockId, blockId))) + .limit(1) + + if (existingForm.length === 0) { + return new NextResponse('Form not found', { status: 404 }) + } + + const result = await db + .update(workflowForm) + .set({ + title, + description, + formConfig, + styling: styling || {}, + settings: settings || {}, + updatedAt: new Date(), + }) + .where(eq(workflowForm.id, existingForm[0].id)) + .returning() + + return NextResponse.json({ + id: result[0].id, + path: result[0].path, + title: result[0].title, + formConfig: result[0].formConfig, + }) + } catch (error: any) { + console.error('Error updating form:', error) + return new NextResponse(`Internal Server Error: ${error.message}`, { + status: 500, + }) + } +} diff --git a/apps/sim/app/form/[formId]/components/form-renderer.tsx b/apps/sim/app/form/[formId]/components/form-renderer.tsx new file mode 100644 index 0000000000..f7c693a40b --- /dev/null +++ b/apps/sim/app/form/[formId]/components/form-renderer.tsx @@ -0,0 +1,261 @@ +'use client' + +import { useState } from 'react' +import { Loader2 } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Checkbox } from '@/components/ui/checkbox' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { Textarea } from '@/components/ui/textarea' +import { quickValidateEmail } from '@/lib/email/validation' +import type { FormConfig, FormField, FormSettings } from '@/lib/types/form' + +interface FormRendererProps { + formId: string + formConfig: FormConfig + styling?: Record + settings: FormSettings +} + +export function FormRenderer({ formId, formConfig, styling, settings }: FormRendererProps) { + const [formData, setFormData] = useState>({}) + const [isSubmitting, setIsSubmitting] = useState(false) + const [isSubmitted, setIsSubmitted] = useState(false) + const [errors, setErrors] = useState>({}) + const [submitError, setSubmitError] = useState(null) + + const handleFieldChange = (fieldName: string, value: any) => { + setFormData((prev) => ({ + ...prev, + [fieldName]: value, + })) + + if (errors[fieldName]) { + setErrors((prev) => ({ + ...prev, + [fieldName]: '', + })) + } + } + + const validateForm = () => { + const newErrors: Record = {} + + formConfig.fields.forEach((field) => { + if (field.required) { + const value = formData[field.name] + if (!value || (typeof value === 'string' && value.trim() === '')) { + newErrors[field.name] = `${field.label} is required` + } + } + + if (field.type === 'email' && formData[field.name]) { + const validation = quickValidateEmail(formData[field.name]) + if (!validation.isValid) { + newErrors[field.name] = validation.reason || 'Please enter a valid email address' + } + } + }) + + setErrors(newErrors) + return Object.keys(newErrors).length === 0 + } + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + + if (!validateForm()) { + return + } + + setIsSubmitting(true) + setSubmitError(null) + + try { + const response = await fetch(`/api/forms/${formId}/submit`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(formData), + }) + + if (!response.ok) { + const errorData = await response.json() + throw new Error(errorData.error || 'Form submission failed') + } + + const result = await response.json() + + setIsSubmitted(true) + + if (result.redirectUrl) { + setTimeout(() => { + window.location.href = result.redirectUrl + }, 2000) + } + } catch (error: any) { + setSubmitError(error.message || 'Something went wrong. Please try again.') + } finally { + setIsSubmitting(false) + } + } + + const renderField = (field: FormField) => { + const value = formData[field.name] || '' + + const commonProps = { + id: field.name, + name: field.name, + placeholder: field.placeholder, + required: field.required, + } + + switch (field.type) { + case 'text': + return ( + handleFieldChange(field.name, e.target.value)} + /> + ) + + case 'email': + return ( + handleFieldChange(field.name, e.target.value)} + /> + ) + + case 'number': + return ( + handleFieldChange(field.name, e.target.value)} + /> + ) + + case 'textarea': + return ( +