"""Form Actions API — CRUD endpoints for managing post-submission actions.
Each site can have multiple actions (webhook, email, log, redirect, document).
Actions are executed in execution_order after each submission.
"""
from flask import Blueprint, jsonify, request
from app.models import (
TIERS,
create_action,
delete_action,
get_action,
get_actions_by_site,
get_site,
toggle_action,
update_action,
)
from app.routes.auth import current_user, login_required
actions_bp = Blueprint("actions", __name__)
VALID_ACTION_TYPES = {"webhook", "email", "log", "redirect", "document", "chain"}
@actions_bp.route("/sites/<int:site_id>/actions", methods=["GET"])
@login_required
def list_actions(site_id):
"""List all actions for a site."""
site = get_site(site_id)
if not site:
return jsonify({"error": "Site not found"}), 404
# Verify ownership
user = current_user()
if not _user_owns_site(user, site):
return jsonify({"error": "Forbidden"}), 403
actions = get_actions_by_site(site_id)
return jsonify({"actions": actions}), 200
@actions_bp.route("/sites/<int:site_id>/actions", methods=["POST"])
@login_required
def create_new_action(site_id):
"""Create a new action for a site."""
site = get_site(site_id)
if not site:
return jsonify({"error": "Site not found"}), 404
user = current_user()
if not _user_owns_site(user, site):
return jsonify({"error": "Forbidden"}), 403
data = request.get_json(force=True)
action_type = data.get("type", "").lower()
if action_type not in VALID_ACTION_TYPES:
return jsonify({"error": f"Invalid action type. Must be one of: {', '.join(sorted(VALID_ACTION_TYPES))}"}), 400
# Check tier limits for chain actions
if action_type == "chain":
from app.models import check_chain_quota
allowed, current_count, max_chains = check_chain_quota(user["id"])
if not allowed:
tier_config = TIERS.get(user.get("tier", "free"), TIERS["free"])
if max_chains == 0:
return jsonify(
{
"error": "Action chains are not available on your current plan",
"upgrade_required": True,
"current_tier": user.get("tier", "free"),
}
), 403
return jsonify(
{
"error": f"Action chain limit reached ({current_count}/{max_chains})",
"upgrade_required": True,
"current_count": current_count,
"max_chains": max_chains,
"current_tier": user.get("tier", "free"),
}
), 403
config = data.get("config", {})
trigger_event = data.get("trigger_event", "submission")
enabled = data.get("enabled", True)
execution_order = data.get("execution_order", 0)
# Validate config has required fields per type
if action_type == "webhook" and not config.get("url"):
return jsonify({"error": "Webhook action requires 'url' in config"}), 400
if action_type == "email" and not config.get("recipients"):
return jsonify({"error": "Email action requires 'recipients' in config"}), 400
if action_type == "redirect" and not config.get("url"):
return jsonify({"error": "Redirect action requires 'url' in config"}), 400
if action_type == "document" and not config.get("template_id"):
return jsonify({"error": "Document action requires 'template_id' in config"}), 400
if action_type == "chain":
from app.services.chain_engine import validate_chain
validation = validate_chain(config)
if not validation["valid"]:
return jsonify({"error": "Invalid chain", "details": validation["errors"]}), 400
action_id = create_action(
site_id=site_id,
action_type=action_type,
config=config,
trigger_event=trigger_event,
enabled=enabled,
execution_order=execution_order,
)
action = get_action(action_id)
return jsonify({"action": action}), 201
@actions_bp.route("/actions/<int:action_id>", methods=["GET"])
@login_required
def get_action_endpoint(action_id):
"""Get a single action by ID."""
action = get_action(action_id)
if not action:
return jsonify({"error": "Action not found"}), 404
# Verify ownership via site
user = current_user()
site = get_site(action["site_id"])
if not site or not _user_owns_site(user, site):
return jsonify({"error": "Forbidden"}), 403
return jsonify({"action": action}), 200
@actions_bp.route("/actions/<int:action_id>", methods=["PUT"])
@login_required
def update_action_endpoint(action_id):
"""Update an existing action."""
action = get_action(action_id)
if not action:
return jsonify({"error": "Action not found"}), 404
user = current_user()
site = get_site(action["site_id"])
if not site or not _user_owns_site(user, site):
return jsonify({"error": "Forbidden"}), 403
data = request.get_json(force=True)
# Validate type if changing
if "type" in data:
new_type = data["type"].lower()
if new_type not in VALID_ACTION_TYPES:
return jsonify(
{"error": f"Invalid action type. Must be one of: {', '.join(sorted(VALID_ACTION_TYPES))}"}
), 400
# Validate config if changing type
action_type = data.get("type", action["type"])
config = data.get("config", action.get("config", {}))
if action_type == "webhook" and not config.get("url"):
return jsonify({"error": "Webhook action requires 'url' in config"}), 400
if action_type == "email" and not config.get("recipients"):
return jsonify({"error": "Email action requires 'recipients' in config"}), 400
if action_type == "redirect" and not config.get("url"):
return jsonify({"error": "Redirect action requires 'url' in config"}), 400
if action_type == "document" and not config.get("template_id"):
return jsonify({"error": "Document action requires 'template_id' in config"}), 400
if action_type == "chain":
from app.services.chain_engine import validate_chain
validation = validate_chain(config)
if not validation["valid"]:
return jsonify({"error": "Invalid chain", "details": validation["errors"]}), 400
update_action(action_id, **data)
updated = get_action(action_id)
return jsonify({"action": updated}), 200
@actions_bp.route("/actions/<int:action_id>", methods=["DELETE"])
@login_required
def delete_action_endpoint(action_id):
"""Delete an action."""
action = get_action(action_id)
if not action:
return jsonify({"error": "Action not found"}), 404
user = current_user()
site = get_site(action["site_id"])
if not site or not _user_owns_site(user, site):
return jsonify({"error": "Forbidden"}), 403
delete_action(action_id)
return jsonify({"success": True}), 200
@actions_bp.route("/actions/<int:action_id>/toggle", methods=["POST"])
@login_required
def toggle_action_endpoint(action_id):
"""Toggle action enabled/disabled."""
action = get_action(action_id)
if not action:
return jsonify({"error": "Action not found"}), 404
user = current_user()
site = get_site(action["site_id"])
if not site or not _user_owns_site(user, site):
return jsonify({"error": "Forbidden"}), 403
new_state = toggle_action(action_id)
if new_state is None:
return jsonify({"error": "Action not found"}), 404
updated = get_action(action_id)
return jsonify({"action": updated, "enabled": new_state}), 200
def _user_owns_site(user, site):
"""Check if user owns the site (or has team access)."""
if not user:
return False
return user.get("id") == site.get("user_id")
@actions_bp.route("/validate/chain", methods=["POST"])
@login_required
def validate_chain_endpoint():
"""Validate a chain configuration without creating it."""
from app.services.chain_engine import validate_chain
data = request.get_json(force=True)
config = data.get("config", {})
validation = validate_chain(config)
return jsonify(validation), 200 if validation["valid"] else 400
@actions_bp.route("/chains/quota", methods=["GET"])
@login_required
def chain_quota():
"""Get current chain usage and limits for the authenticated user."""
from app.models import check_chain_quota, get_user_chain_count
user = current_user()
allowed, current_count, max_chains = check_chain_quota(user["id"])
tier_config = TIERS.get(user.get("tier", "free"), TIERS["free"])
return jsonify(
{
"allowed": allowed,
"current_count": current_count,
"max_chains": max_chains,
"unlimited": max_chains == -1,
"available": max_chains - current_count if max_chains > 0 else None,
"tier": user.get("tier", "free"),
"tier_name": tier_config["name"],
}
), 200