mirror of
https://github.com/warmbly/warmbly.git
synced 2026-09-10 16:04:55 +00:00
422 lines
14 KiB
Go
422 lines
14 KiB
Go
package aitools
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/warmbly/warmbly/internal/models"
|
|
"github.com/warmbly/warmbly/internal/pkg/generation"
|
|
)
|
|
|
|
// Hosted form tools, gated on the contact permissions like the HTTP routes.
|
|
// Brand-asset uploads (multipart images) and the DNS-checked forms domain are
|
|
// deliberately absent.
|
|
func (d Deps) registerFormTools(r *Registry) {
|
|
if d.Forms == nil {
|
|
return
|
|
}
|
|
|
|
// One block. The layout kinds render but collect nothing.
|
|
fieldItem := objectSchema(map[string]any{
|
|
"id": strProp("Short lowercase slug, stable across edits; answers are keyed by it."),
|
|
"type": enumProp("The block kind.", "text", "email", "phone", "textarea", "number", "select", "radio", "checkboxes", "checkbox", "date", "hidden", "heading", "paragraph", "divider", "page_break"),
|
|
"label": strProp("The visible label. Required for every input block except hidden."),
|
|
"placeholder": strProp("Optional placeholder text."),
|
|
"help_text": strProp("Optional hint under the field."),
|
|
"required": boolProp("Whether an answer is required to submit."),
|
|
"options": arrProp("The choices, for select, radio and checkboxes.", map[string]any{"type": "string"}),
|
|
"map_to": enumProp("Fill this contact column with the answer. Without it the answer lands in the contact's custom fields under the label.", "first_name", "last_name", "email", "company", "phone"),
|
|
"value": strProp("The constant a hidden field submits, or the body text of a paragraph block."),
|
|
"width": enumProp("full, or half so two fields share a row.", "full", "half"),
|
|
}, "id", "type")
|
|
|
|
r.Register(Tool{
|
|
Name: "list_forms",
|
|
Description: "List the workspace's hosted forms with their status, public URL, and view/submission counts.",
|
|
InputSchema: objectSchema(map[string]any{}),
|
|
Risk: generation.RiskRead,
|
|
RequiredOrgPerm: models.PermViewContacts,
|
|
RequiredAPIPerm: models.APIPermReadContacts,
|
|
Handler: d.listForms,
|
|
})
|
|
|
|
r.Register(Tool{
|
|
Name: "get_form",
|
|
Description: "Get one form: its blocks, design, target campaign, categories, and public URL.",
|
|
InputSchema: objectSchema(map[string]any{"form_id": strProp("The form's UUID.")}, "form_id"),
|
|
Risk: generation.RiskRead,
|
|
RequiredOrgPerm: models.PermViewContacts,
|
|
RequiredAPIPerm: models.APIPermReadContacts,
|
|
Handler: d.getForm,
|
|
})
|
|
|
|
r.Register(Tool{
|
|
Name: "create_form",
|
|
Description: "Create a form. It starts as an unpublished draft with no blocks; add them with update_form, then publish it by setting status to published.",
|
|
InputSchema: objectSchema(map[string]any{"name": strProp("Internal name for the form (required).")}, "name"),
|
|
Risk: generation.RiskWrite,
|
|
RequiredOrgPerm: models.PermManageContacts,
|
|
RequiredAPIPerm: models.APIPermWriteContacts,
|
|
Handler: d.createForm,
|
|
})
|
|
|
|
r.Register(Tool{
|
|
Name: "update_form",
|
|
Description: "Update a form. Omitted fields keep their stored value; sending fields replaces the whole block list. Only a published form renders and accepts submissions.",
|
|
InputSchema: objectSchema(map[string]any{
|
|
"form_id": strProp("The form's UUID."),
|
|
"name": strProp("New internal name."),
|
|
"status": enumProp("draft hides it, published puts it live, archived keeps the data but takes the page offline.", "draft", "published", "archived"),
|
|
"fields": arrProp("Replacement block list, in display order.", fieldItem),
|
|
"success_message": strProp("Shown after a successful submit."),
|
|
"redirect_url": strProp("Send the visitor here instead of showing the success message."),
|
|
"campaign_id": strProp("Add every contact this form creates to this campaign. Pass an empty string to detach."),
|
|
"category_ids": arrProp("Categories to file new contacts under.", map[string]any{"type": "string"}),
|
|
"allowed_domains": arrProp("Domains the form may be embedded on.", map[string]any{"type": "string"}),
|
|
"captcha_enabled": boolProp("Require a captcha. Has no effect where the install has no captcha provider."),
|
|
}, "form_id"),
|
|
Risk: generation.RiskWrite,
|
|
RequiredOrgPerm: models.PermManageContacts,
|
|
RequiredAPIPerm: models.APIPermWriteContacts,
|
|
Handler: d.updateForm,
|
|
})
|
|
|
|
r.Register(Tool{
|
|
Name: "delete_form",
|
|
Description: "Delete a form and every submission it captured. The contacts it created are not touched. Requires user approval.",
|
|
InputSchema: objectSchema(map[string]any{"form_id": strProp("The form's UUID.")}, "form_id"),
|
|
Risk: generation.RiskWrite,
|
|
RequiredOrgPerm: models.PermManageContacts,
|
|
RequiredAPIPerm: models.APIPermWriteContacts,
|
|
Handler: d.deleteForm,
|
|
})
|
|
|
|
r.Register(Tool{
|
|
Name: "list_form_submissions",
|
|
Description: "List a form's submissions, newest first, with the contact each one created or updated.",
|
|
InputSchema: objectSchema(map[string]any{
|
|
"form_id": strProp("The form's UUID."),
|
|
"limit": intProp("Max submissions (1-100, default 50)."),
|
|
}, "form_id"),
|
|
Risk: generation.RiskRead,
|
|
RequiredOrgPerm: models.PermViewContacts,
|
|
RequiredAPIPerm: models.APIPermReadContacts,
|
|
Handler: d.listFormSubmissions,
|
|
})
|
|
|
|
r.Register(Tool{
|
|
Name: "get_form_stats",
|
|
Description: "Get a form's funnel over a date range: views, starts, submissions, completion rate, per-page drop-off, and breakdowns by source, country, device and campaign.",
|
|
InputSchema: objectSchema(map[string]any{
|
|
"form_id": strProp("The form's UUID."),
|
|
"range": enumProp("The window to report on. Defaults to 30d.", "7d", "30d", "90d"),
|
|
}, "form_id"),
|
|
Risk: generation.RiskRead,
|
|
RequiredOrgPerm: models.PermViewContacts,
|
|
RequiredAPIPerm: models.APIPermReadContacts,
|
|
Handler: d.getFormStats,
|
|
})
|
|
|
|
r.Register(Tool{
|
|
Name: "get_campaign_forms",
|
|
Description: "Report how the forms linked from a campaign performed for that campaign's recipients: links sent, viewers, starters, submissions.",
|
|
InputSchema: objectSchema(map[string]any{"campaign_id": strProp("The campaign's UUID.")}, "campaign_id"),
|
|
Risk: generation.RiskRead,
|
|
RequiredOrgPerm: models.PermViewCampaigns,
|
|
RequiredAPIPerm: models.APIPermReadCampaigns,
|
|
Handler: d.getCampaignForms,
|
|
})
|
|
|
|
r.Register(Tool{
|
|
Name: "mint_form_link",
|
|
Description: "Get the personalized form URL for one contact, so a submission is tied back to them. Minting the same link twice returns the same URL.",
|
|
InputSchema: objectSchema(map[string]any{
|
|
"form_id": strProp("The form's UUID."),
|
|
"contact_id": strProp("The contact's UUID."),
|
|
}, "form_id", "contact_id"),
|
|
// Idempotent, so it stays out of the approval loop, but it upserts a
|
|
// ticket row, hence the write permission. Same shape as
|
|
// get_invitation_link.
|
|
Risk: generation.RiskRead,
|
|
RequiredOrgPerm: models.PermManageContacts,
|
|
RequiredAPIPerm: models.APIPermWriteContacts,
|
|
Handler: d.mintFormLink,
|
|
})
|
|
}
|
|
|
|
func (d Deps) listForms(ctx context.Context, inv Invocation, _ json.RawMessage) (string, error) {
|
|
out, xerr := d.Forms.List(ctx, inv.OrgID)
|
|
if xerr != nil {
|
|
return "", fromErrx(xerr)
|
|
}
|
|
return jsonResult(out)
|
|
}
|
|
|
|
func (d Deps) getForm(ctx context.Context, inv Invocation, args json.RawMessage) (string, error) {
|
|
in, err := decodeArgs[struct {
|
|
FormID string `json:"form_id"`
|
|
}](args)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
id, err := parseUUIDArg(in.FormID)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
out, xerr := d.Forms.Get(ctx, inv.OrgID, id)
|
|
if xerr != nil {
|
|
return "", fromErrx(xerr)
|
|
}
|
|
return jsonResult(out)
|
|
}
|
|
|
|
func (d Deps) createForm(ctx context.Context, inv Invocation, args json.RawMessage) (string, error) {
|
|
in, err := decodeArgs[struct {
|
|
Name string `json:"name"`
|
|
}](args)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if in.Name == "" {
|
|
return "", ErrInvalidArgs
|
|
}
|
|
createdBy := inv.UserID
|
|
out, xerr := d.Forms.Create(ctx, inv.OrgID, &createdBy, in.Name)
|
|
if xerr != nil {
|
|
return "", fromErrx(xerr)
|
|
}
|
|
d.logAudit(ctx, inv, models.AuditActionCreate, models.AuditEntityForm, &out.ID, map[string]string{"name": out.Name})
|
|
return jsonResult(out)
|
|
}
|
|
|
|
func (d Deps) updateForm(ctx context.Context, inv Invocation, args json.RawMessage) (string, error) {
|
|
in, err := decodeArgs[struct {
|
|
FormID string `json:"form_id"`
|
|
Name *string `json:"name"`
|
|
Status string `json:"status"`
|
|
Fields *[]toolFormField `json:"fields"`
|
|
SuccessMessage *string `json:"success_message"`
|
|
RedirectURL *string `json:"redirect_url"`
|
|
CampaignID *string `json:"campaign_id"`
|
|
CategoryIDs *[]string `json:"category_ids"`
|
|
AllowedDomains *[]string `json:"allowed_domains"`
|
|
CaptchaEnabled *bool `json:"captcha_enabled"`
|
|
}](args)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
id, err := parseUUIDArg(in.FormID)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
write := &models.FormWrite{
|
|
Name: in.Name,
|
|
SuccessMessage: in.SuccessMessage,
|
|
RedirectURL: in.RedirectURL,
|
|
AllowedDomains: in.AllowedDomains,
|
|
CaptchaEnabled: in.CaptchaEnabled,
|
|
}
|
|
if in.Status != "" {
|
|
status := models.FormStatus(in.Status)
|
|
if !status.Valid() {
|
|
return "", ErrInvalidArgs
|
|
}
|
|
write.Status = &status
|
|
}
|
|
if in.Fields != nil {
|
|
fields, ferr := toFormFields(*in.Fields)
|
|
if ferr != nil {
|
|
return "", ferr
|
|
}
|
|
write.Fields = &fields
|
|
}
|
|
// "" detaches; the nullable wrapper tells "clear it" from "leave it".
|
|
if in.CampaignID != nil {
|
|
if *in.CampaignID == "" {
|
|
write.CampaignID = models.NullableUUID{Set: true}
|
|
} else {
|
|
cid, cerr := parseUUIDArg(*in.CampaignID)
|
|
if cerr != nil {
|
|
return "", cerr
|
|
}
|
|
write.CampaignID = models.NullableUUID{Set: true, Value: &cid}
|
|
}
|
|
}
|
|
if in.CategoryIDs != nil {
|
|
ids := make([]uuid.UUID, 0, len(*in.CategoryIDs))
|
|
for _, raw := range *in.CategoryIDs {
|
|
cid, cerr := parseUUIDArg(raw)
|
|
if cerr != nil {
|
|
return "", cerr
|
|
}
|
|
ids = append(ids, cid)
|
|
}
|
|
write.CategoryIDs = &ids
|
|
}
|
|
|
|
out, xerr := d.Forms.Update(ctx, inv.OrgID, id, write)
|
|
if xerr != nil {
|
|
return "", fromErrx(xerr)
|
|
}
|
|
d.logAudit(ctx, inv, models.AuditActionUpdate, models.AuditEntityForm, &out.ID, map[string]string{
|
|
"name": out.Name, "status": string(out.Status),
|
|
})
|
|
return jsonResult(out)
|
|
}
|
|
|
|
func (d Deps) deleteForm(ctx context.Context, inv Invocation, args json.RawMessage) (string, error) {
|
|
in, err := decodeArgs[struct {
|
|
FormID string `json:"form_id"`
|
|
}](args)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
id, err := parseUUIDArg(in.FormID)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if xerr := d.Forms.Delete(ctx, inv.OrgID, id); xerr != nil {
|
|
return "", fromErrx(xerr)
|
|
}
|
|
d.logAudit(ctx, inv, models.AuditActionDelete, models.AuditEntityForm, &id, nil)
|
|
return jsonResult(map[string]any{"ok": true, "form_id": id.String()})
|
|
}
|
|
|
|
func (d Deps) listFormSubmissions(ctx context.Context, inv Invocation, args json.RawMessage) (string, error) {
|
|
in, err := decodeArgs[struct {
|
|
FormID string `json:"form_id"`
|
|
Limit int `json:"limit"`
|
|
}](args)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
id, err := parseUUIDArg(in.FormID)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
limit := in.Limit
|
|
if limit <= 0 || limit > 100 {
|
|
limit = 50
|
|
}
|
|
var before *time.Time
|
|
out, hasMore, xerr := d.Forms.ListSubmissions(ctx, inv.OrgID, id, limit, before)
|
|
if xerr != nil {
|
|
return "", fromErrx(xerr)
|
|
}
|
|
return jsonResult(map[string]any{"data": out, "has_more": hasMore})
|
|
}
|
|
|
|
func (d Deps) getFormStats(ctx context.Context, inv Invocation, args json.RawMessage) (string, error) {
|
|
in, err := decodeArgs[struct {
|
|
FormID string `json:"form_id"`
|
|
Range string `json:"range"`
|
|
}](args)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
id, err := parseUUIDArg(in.FormID)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
days := 30
|
|
switch in.Range {
|
|
case "", "30d":
|
|
case "7d":
|
|
days = 7
|
|
case "90d":
|
|
days = 90
|
|
default:
|
|
return "", ErrInvalidArgs
|
|
}
|
|
out, xerr := d.Forms.Stats(ctx, inv.OrgID, id, days)
|
|
if xerr != nil {
|
|
return "", fromErrx(xerr)
|
|
}
|
|
return jsonResult(out)
|
|
}
|
|
|
|
func (d Deps) getCampaignForms(ctx context.Context, inv Invocation, args json.RawMessage) (string, error) {
|
|
in, err := decodeArgs[struct {
|
|
CampaignID string `json:"campaign_id"`
|
|
}](args)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
cid, err := parseUUIDArg(in.CampaignID)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
out, xerr := d.Forms.CampaignForms(ctx, inv.OrgID, cid)
|
|
if xerr != nil {
|
|
return "", fromErrx(xerr)
|
|
}
|
|
return jsonResult(out)
|
|
}
|
|
|
|
func (d Deps) mintFormLink(ctx context.Context, inv Invocation, args json.RawMessage) (string, error) {
|
|
in, err := decodeArgs[struct {
|
|
FormID string `json:"form_id"`
|
|
ContactID string `json:"contact_id"`
|
|
}](args)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
fid, err := parseUUIDArg(in.FormID)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
cid, err := parseUUIDArg(in.ContactID)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
url, xerr := d.Forms.MintLink(ctx, inv.OrgID, fid, cid)
|
|
if xerr != nil {
|
|
return "", fromErrx(xerr)
|
|
}
|
|
return jsonResult(map[string]any{"url": url})
|
|
}
|
|
|
|
// Model-facing block shape, kept off the stored struct's json tags.
|
|
type toolFormField struct {
|
|
ID string `json:"id"`
|
|
Type string `json:"type"`
|
|
Label string `json:"label"`
|
|
Placeholder string `json:"placeholder"`
|
|
HelpText string `json:"help_text"`
|
|
Required bool `json:"required"`
|
|
Options []string `json:"options"`
|
|
MapTo string `json:"map_to"`
|
|
Value string `json:"value"`
|
|
Width string `json:"width"`
|
|
Rows int `json:"rows"`
|
|
}
|
|
|
|
// The service does the real validation; this only rejects an unknown block
|
|
// type, so a bad one is a correctable argument error, not a round-trip.
|
|
func toFormFields(in []toolFormField) ([]models.FormField, error) {
|
|
out := make([]models.FormField, 0, len(in))
|
|
for _, f := range in {
|
|
ft := models.FormFieldType(f.Type)
|
|
if !ft.Valid() {
|
|
return nil, ErrInvalidArgs
|
|
}
|
|
out = append(out, models.FormField{
|
|
ID: f.ID,
|
|
Type: ft,
|
|
Label: f.Label,
|
|
Placeholder: f.Placeholder,
|
|
HelpText: f.HelpText,
|
|
Required: f.Required,
|
|
Options: f.Options,
|
|
MapTo: f.MapTo,
|
|
Value: f.Value,
|
|
Width: f.Width,
|
|
Rows: f.Rows,
|
|
})
|
|
}
|
|
return out, nil
|
|
}
|