Files
warmbly/internal/app/aitools/tools_segments.go
T
Matthew Meszaros 50711a8e66 fix: a campaign's linked segments are its audience, so detaching one withdraws its leads (#510) (#523)
* fix: make a campaign's linked segments the audience rather than an accumulator, so detaching one withdraws the leads it enrolled instead of leaving the old list mixed in with the new, tracked by a new campaign_leads.source that keeps a hand-picked lead, an overlapping segment's member and anyone the campaign has already emailed out of the withdrawal, and reported back as withdrawn/contacted counts the dialog confirms and explains (issue #510)

* fix: serialize the linked-segment sweep against a link replacement by taking the same campaign lock, so a sweep that read the old set cannot re-enrol the audience the replacement just withdrew, and word the dialog's confirm and toast so the campaign, not the segment, is what has already emailed a lead

* fix: stop the one-shot segment enrol from re-stamping leads that are already in the campaign, since the Leads tab's Add back runs through it and pinning a whole linked audience as hand-picked because one held-out member was restored is the accumulation this change exists to end

* fix: lock the leads a detachment is about to withdraw before deciding, because a send is reserved by stamping campaign_contact_progress and only then locking the lead row, so a reservation committing mid-pass was invisible to the delete's snapshot and could withdraw a lead whose first email had already gone; and report the already-emailed count on every toast branch, since a detach where the whole audience had been emailed changed no count and said nothing after confirming a removal

* fix: add the campaign_leads.source check constraint NOT VALID, which still enforces every insert and update while skipping a full scan of the largest table in the product under ACCESS EXCLUSIVE to learn that a one-statement-old column holds its own default everywhere
2026-09-14 22:12:15 -07:00

458 lines
16 KiB
Go

package aitools
import (
"context"
"encoding/json"
"strconv"
"github.com/warmbly/warmbly/internal/models"
"github.com/warmbly/warmbly/internal/pkg/generation"
)
// Segment tools, gated on the contact permissions like the HTTP routes, except
// the two campaign-linking ones: attaching an audience is a campaign write.
func (d Deps) registerSegmentTools(r *Registry) {
if d.Segments == nil {
return
}
// Shared by create, update and preview.
conditionItem := objectSchema(map[string]any{
"field": strProp("Field name from list_segment_fields, or \"custom.<key>\" for a custom field."),
"operator": strProp("An operator that field's kind accepts, from list_segment_fields."),
"value": strProp("The value, for scalar operators. A date field takes YYYY-MM-DD; within_days takes a whole number of days."),
"values": arrProp("The values, for the list operators in / not_in.", map[string]any{"type": "string"}),
}, "field", "operator")
r.Register(Tool{
Name: "list_segments",
Description: "List the workspace's saved contact segments with their live membership counts.",
InputSchema: objectSchema(map[string]any{}),
Risk: generation.RiskRead,
RequiredOrgPerm: models.PermViewContacts,
RequiredAPIPerm: models.APIPermReadContacts,
Handler: d.listSegments,
})
r.Register(Tool{
Name: "get_segment",
Description: "Get one segment: its conditions, match mode, and how many contacts it currently holds.",
InputSchema: objectSchema(map[string]any{"segment_id": strProp("The segment's UUID.")}, "segment_id"),
Risk: generation.RiskRead,
RequiredOrgPerm: models.PermViewContacts,
RequiredAPIPerm: models.APIPermReadContacts,
Handler: d.getSegment,
})
r.Register(Tool{
Name: "list_segment_fields",
Description: "List every field a segment condition can name, with its kind and the operators it accepts. Call this before building conditions: the vocabulary includes this workspace's own custom fields and cannot be guessed.",
InputSchema: objectSchema(map[string]any{}),
Risk: generation.RiskRead,
RequiredOrgPerm: models.PermViewContacts,
RequiredAPIPerm: models.APIPermReadContacts,
Handler: d.listSegmentFields,
})
r.Register(Tool{
Name: "preview_segment",
Description: "Count the contacts a set of conditions would match, without saving anything. Use it to check an audience is the size you expect before creating the segment.",
InputSchema: objectSchema(map[string]any{
"match": enumProp("Whether every condition must hold, or any one of them. Defaults to all.", "all", "any"),
"conditions": arrProp("The conditions to evaluate.", conditionItem),
"segment_id": strProp("An existing segment whose manual overrides should be kept in the count, when previewing an edit to it."),
}, "conditions"),
Risk: generation.RiskRead,
RequiredOrgPerm: models.PermViewContacts,
RequiredAPIPerm: models.APIPermReadContacts,
Handler: d.previewSegment,
})
r.Register(Tool{
Name: "create_segment",
Description: "Create a saved contact segment. Membership is evaluated live, so the segment keeps up with the contacts on its own.",
InputSchema: objectSchema(map[string]any{
"name": strProp("A short name for the audience (required)."),
"description": strProp("Optional longer description."),
"color": strProp("Optional #rrggbb swatch for the dashboard."),
"match": enumProp("Whether every condition must hold, or any one of them. Defaults to all.", "all", "any"),
"conditions": arrProp("The membership conditions.", conditionItem),
}, "name", "conditions"),
Risk: generation.RiskWrite,
RequiredOrgPerm: models.PermManageContacts,
RequiredAPIPerm: models.APIPermWriteContacts,
Handler: d.createSegment,
})
r.Register(Tool{
Name: "update_segment",
Description: "Update a segment's name, description, colour, match mode, or conditions. Omitted fields keep their stored value; sending conditions replaces the whole list.",
InputSchema: objectSchema(map[string]any{
"segment_id": strProp("The segment's UUID."),
"name": strProp("New name."),
"description": strProp("New description."),
"color": strProp("New #rrggbb swatch."),
"match": enumProp("New match mode.", "all", "any"),
"conditions": arrProp("Replacement condition list.", conditionItem),
}, "segment_id"),
Risk: generation.RiskWrite,
RequiredOrgPerm: models.PermManageContacts,
RequiredAPIPerm: models.APIPermWriteContacts,
Handler: d.updateSegment,
})
r.Register(Tool{
Name: "delete_segment",
Description: "Delete a segment. The contacts in it are not touched, but any campaign fed by it stops receiving new members. Requires user approval.",
InputSchema: objectSchema(map[string]any{"segment_id": strProp("The segment's UUID.")}, "segment_id"),
Risk: generation.RiskWrite,
RequiredOrgPerm: models.PermManageContacts,
RequiredAPIPerm: models.APIPermWriteContacts,
Handler: d.deleteSegment,
})
r.Register(Tool{
Name: "set_segment_members",
Description: "Pin contacts into or out of a segment regardless of what its conditions say, or clear that override so the conditions decide again.",
InputSchema: objectSchema(map[string]any{
"segment_id": strProp("The segment's UUID."),
"contacts": arrProp("Contact UUIDs to override.", map[string]any{"type": "string"}),
"mode": enumProp("include pins them in, exclude pins them out, auto clears the override.", "include", "exclude", "auto"),
}, "segment_id", "contacts", "mode"),
Risk: generation.RiskWrite,
RequiredOrgPerm: models.PermManageContacts,
RequiredAPIPerm: models.APIPermWriteContacts,
Handler: d.setSegmentMembers,
})
r.Register(Tool{
Name: "list_campaign_segments",
Description: "List the segments feeding a campaign, with how many of each one's members are already leads and how many are held out.",
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.listCampaignSegments,
})
r.Register(Tool{
Name: "set_campaign_segments",
Description: "Replace the set of segments feeding a campaign. A linked segment is a live audience source: its members are enrolled as leads now and kept enrolled as the segment changes. This is the whole set, so pass every segment you want attached, or an empty list to detach them all. Requires user approval.",
InputSchema: objectSchema(map[string]any{
"campaign_id": strProp("The campaign's UUID."),
"segment_ids": arrProp("The segment UUIDs to link, at most 20. An empty list detaches every segment.", map[string]any{"type": "string"}),
}, "campaign_id", "segment_ids"),
Risk: generation.RiskWrite,
// Linking an audience changes who a campaign mails.
RequiredOrgPerm: models.PermManageCampaigns,
RequiredAPIPerm: models.APIPermWriteCampaigns,
Handler: d.setCampaignSegments,
})
r.Register(Tool{
Name: "add_segment_to_campaign",
Description: "Enrol a segment's current members as leads of a campaign, once. This is a one-off copy: later members are NOT added. Use set_campaign_segments instead to keep the audience live. Requires user approval.",
InputSchema: objectSchema(map[string]any{
"segment_id": strProp("The segment's UUID."),
"campaign_id": strProp("The campaign to enrol them into."),
}, "segment_id", "campaign_id"),
Risk: generation.RiskWrite,
RequiredOrgPerm: models.PermManageCampaigns,
RequiredAPIPerm: models.APIPermWriteCampaigns,
Handler: d.addSegmentToCampaign,
})
}
// Model-facing condition shape, kept off the stored struct's json tags.
type toolCondition struct {
Field string `json:"field"`
Operator string `json:"operator"`
Value string `json:"value"`
Values []string `json:"values"`
}
func toSegmentConditions(in []toolCondition) []models.SegmentCondition {
out := make([]models.SegmentCondition, 0, len(in))
for _, c := range in {
out = append(out, models.SegmentCondition{
Field: c.Field,
Operator: c.Operator,
Value: c.Value,
Values: c.Values,
})
}
return out
}
// Defaults to "all"; an unknown mode is rejected, since "any" can differ by the
// whole audience.
func segmentMatch(s string) (models.SegmentMatch, error) {
switch s {
case "":
return models.SegmentMatchAll, nil
case string(models.SegmentMatchAll), string(models.SegmentMatchAny):
return models.SegmentMatch(s), nil
default:
return "", ErrInvalidArgs
}
}
func (d Deps) listSegments(ctx context.Context, inv Invocation, _ json.RawMessage) (string, error) {
out, xerr := d.Segments.List(ctx, inv.OrgID)
if xerr != nil {
return "", fromErrx(xerr)
}
return jsonResult(out)
}
func (d Deps) getSegment(ctx context.Context, inv Invocation, args json.RawMessage) (string, error) {
in, err := decodeArgs[struct {
SegmentID string `json:"segment_id"`
}](args)
if err != nil {
return "", err
}
id, err := parseUUIDArg(in.SegmentID)
if err != nil {
return "", err
}
out, xerr := d.Segments.Get(ctx, inv.OrgID, id)
if xerr != nil {
return "", fromErrx(xerr)
}
return jsonResult(out)
}
func (d Deps) listSegmentFields(ctx context.Context, inv Invocation, _ json.RawMessage) (string, error) {
out, xerr := d.Segments.Fields(ctx, inv.OrgID)
if xerr != nil {
return "", fromErrx(xerr)
}
return jsonResult(out)
}
func (d Deps) previewSegment(ctx context.Context, inv Invocation, args json.RawMessage) (string, error) {
in, err := decodeArgs[struct {
Match string `json:"match"`
Conditions []toolCondition `json:"conditions"`
SegmentID string `json:"segment_id"`
}](args)
if err != nil {
return "", err
}
match, err := segmentMatch(in.Match)
if err != nil {
return "", err
}
req := &models.SegmentPreview{Match: match, Conditions: toSegmentConditions(in.Conditions)}
if in.SegmentID != "" {
id, perr := parseUUIDArg(in.SegmentID)
if perr != nil {
return "", perr
}
req.ID = &id
}
n, xerr := d.Segments.Preview(ctx, inv.OrgID, req)
if xerr != nil {
return "", fromErrx(xerr)
}
return jsonResult(map[string]any{"contact_count": n})
}
func (d Deps) createSegment(ctx context.Context, inv Invocation, args json.RawMessage) (string, error) {
in, err := decodeArgs[struct {
Name string `json:"name"`
Description *string `json:"description"`
Color *string `json:"color"`
Match string `json:"match"`
Conditions []toolCondition `json:"conditions"`
}](args)
if err != nil {
return "", err
}
if in.Name == "" {
return "", ErrInvalidArgs
}
match, err := segmentMatch(in.Match)
if err != nil {
return "", err
}
conds := toSegmentConditions(in.Conditions)
createdBy := inv.UserID
out, xerr := d.Segments.Create(ctx, inv.OrgID, &createdBy, &models.SegmentWrite{
Name: &in.Name,
Description: in.Description,
Color: in.Color,
Match: &match,
Conditions: &conds,
})
if xerr != nil {
return "", fromErrx(xerr)
}
d.logAudit(ctx, inv, models.AuditActionCreate, models.AuditEntitySegment, &out.ID, map[string]string{"name": out.Name})
return jsonResult(out)
}
func (d Deps) updateSegment(ctx context.Context, inv Invocation, args json.RawMessage) (string, error) {
in, err := decodeArgs[struct {
SegmentID string `json:"segment_id"`
Name *string `json:"name"`
Description *string `json:"description"`
Color *string `json:"color"`
Match string `json:"match"`
Conditions *[]toolCondition `json:"conditions"`
}](args)
if err != nil {
return "", err
}
id, err := parseUUIDArg(in.SegmentID)
if err != nil {
return "", err
}
write := &models.SegmentWrite{Name: in.Name, Description: in.Description, Color: in.Color}
if in.Match != "" {
match, merr := segmentMatch(in.Match)
if merr != nil {
return "", merr
}
write.Match = &match
}
if in.Conditions != nil {
conds := toSegmentConditions(*in.Conditions)
write.Conditions = &conds
}
out, xerr := d.Segments.Update(ctx, inv.OrgID, id, write)
if xerr != nil {
return "", fromErrx(xerr)
}
d.logAudit(ctx, inv, models.AuditActionUpdate, models.AuditEntitySegment, &out.ID, map[string]string{"name": out.Name})
return jsonResult(out)
}
func (d Deps) deleteSegment(ctx context.Context, inv Invocation, args json.RawMessage) (string, error) {
in, err := decodeArgs[struct {
SegmentID string `json:"segment_id"`
}](args)
if err != nil {
return "", err
}
id, err := parseUUIDArg(in.SegmentID)
if err != nil {
return "", err
}
if xerr := d.Segments.Delete(ctx, inv.OrgID, id); xerr != nil {
return "", fromErrx(xerr)
}
d.logAudit(ctx, inv, models.AuditActionDelete, models.AuditEntitySegment, &id, nil)
return jsonResult(map[string]any{"ok": true, "segment_id": id.String()})
}
func (d Deps) setSegmentMembers(ctx context.Context, inv Invocation, args json.RawMessage) (string, error) {
in, err := decodeArgs[struct {
SegmentID string `json:"segment_id"`
Contacts []string `json:"contacts"`
Mode string `json:"mode"`
}](args)
if err != nil {
return "", err
}
id, err := parseUUIDArg(in.SegmentID)
if err != nil {
return "", err
}
mode := models.SegmentMemberMode(in.Mode)
switch mode {
case models.SegmentMemberInclude, models.SegmentMemberExclude, models.SegmentMemberAuto:
default:
return "", ErrInvalidArgs
}
if len(in.Contacts) == 0 {
return "", ErrInvalidArgs
}
n, xerr := d.Segments.SetMembers(ctx, inv.OrgID, id, &models.SegmentMembersWrite{
ContactSelection: models.ContactSelection{Contacts: in.Contacts},
Mode: mode,
})
if xerr != nil {
return "", fromErrx(xerr)
}
d.logAudit(ctx, inv, models.AuditActionUpdate, models.AuditEntitySegment, &id, map[string]string{"members": in.Mode})
return jsonResult(map[string]any{"updated": n})
}
func (d Deps) listCampaignSegments(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.Segments.ListCampaignSegments(ctx, inv.OrgID, cid)
if xerr != nil {
return "", fromErrx(xerr)
}
return jsonResult(out)
}
func (d Deps) setCampaignSegments(ctx context.Context, inv Invocation, args json.RawMessage) (string, error) {
// Pointer so an omitted key is told apart from an explicit [], which means
// detach everything.
in, err := decodeArgs[struct {
CampaignID string `json:"campaign_id"`
SegmentIDs *[]string `json:"segment_ids"`
}](args)
if err != nil {
return "", err
}
cid, err := parseUUIDArg(in.CampaignID)
if err != nil {
return "", err
}
if in.SegmentIDs == nil {
return "", ErrInvalidArgs
}
links, added, change, xerr := d.Segments.SetCampaignSegments(ctx, inv.OrgID, cid, &models.CampaignSegmentsWrite{
SegmentIDs: *in.SegmentIDs,
})
if xerr != nil {
return "", fromErrx(xerr)
}
d.logAudit(ctx, inv, models.AuditActionUpdate, models.AuditEntityCampaign, &cid, map[string]string{
"segments": strconv.Itoa(len(links)), "added": strconv.Itoa(added),
"withdrawn": strconv.Itoa(change.Withdrawn),
})
return jsonResult(map[string]any{"data": links, "added": added, "withdrawn": change.Withdrawn, "contacted": change.Contacted})
}
func (d Deps) addSegmentToCampaign(ctx context.Context, inv Invocation, args json.RawMessage) (string, error) {
in, err := decodeArgs[struct {
SegmentID string `json:"segment_id"`
CampaignID string `json:"campaign_id"`
}](args)
if err != nil {
return "", err
}
sid, err := parseUUIDArg(in.SegmentID)
if err != nil {
return "", err
}
if _, perr := parseUUIDArg(in.CampaignID); perr != nil {
return "", perr
}
res, xerr := d.Segments.AddToCampaign(ctx, inv.OrgID, inv.UserID.String(), sid, &models.SegmentAddToCampaign{
CampaignID: in.CampaignID,
})
if xerr != nil {
return "", fromErrx(xerr)
}
cid := res.CampaignID
d.logAudit(ctx, inv, models.AuditActionUpdate, models.AuditEntityCampaign, &cid, map[string]string{
"segment_id": sid.String(), "added": strconv.Itoa(res.Added),
})
return jsonResult(res)
}