@@ -1241,45 +1241,11 @@ func (as *ActionService) executeSetFieldCustom(ctx *models.ExecutionContext, ste
12411241 return err
12421242 }
12431243
1244- // Validate options and sanitize substituted user content before persisting.
1245- fieldKey := strconv .Itoa (config .CustomFieldID )
1246- cfv := map [string ]any {fieldKey : value }
1247- fieldTypes , err := validation .CustomFieldTypes (as .db , cfv )
1244+ normalized , err := as .normalizeSetFieldCustomValue (config .CustomFieldID , value )
12481245 if err != nil {
1249- return fmt .Errorf ("resolve custom field type: %w" , err )
1250- }
1251- fieldType , ok := fieldTypes [fieldKey ]
1252- if ! ok {
1253- return fmt .Errorf ("set_field: custom field %d does not exist" , config .CustomFieldID )
1254- }
1255- switch fieldType {
1256- case "select" :
1257- // An empty substitution clears the field rather than failing
1258- // option-id validation.
1259- if strings .TrimSpace (value ) == "" {
1260- cfv [fieldKey ] = nil
1261- }
1262- case "multiselect" :
1263- // Multiselect values arrive as the substituted string form of a
1264- // JSON array ("[1,2]") or a CSV of option ids — decode before
1265- // validation so each element is checked against the option set.
1266- cfv [fieldKey ] = parseActionMultiselectValue (value )
1267- case models .CustomFieldTypeBoolean , models .CustomFieldTypeCheckbox :
1268- switch strings .ToLower (strings .TrimSpace (value )) {
1269- case "" :
1270- cfv [fieldKey ] = nil
1271- case "true" :
1272- cfv [fieldKey ] = true
1273- case "false" :
1274- cfv [fieldKey ] = false
1275- default :
1276- return fmt .Errorf ("set_field: custom field %d requires true or false" , config .CustomFieldID )
1277- }
1278- }
1279- if err := validation .ValidateAndNormalizeCustomFieldValues (as .db , cfv ); err != nil {
1280- return fmt .Errorf ("set_field: custom field %d: %w" , config .CustomFieldID , err )
1246+ return err
12811247 }
1282- newValue := cfv [ fieldKey ]
1248+ newValue := normalized . value
12831249
12841250 oldValue , err := as .itemRepo .GetItemCustomFieldValue (itemID , config .CustomFieldID )
12851251 if err != nil {
@@ -1299,15 +1265,15 @@ func (as *ActionService) executeSetFieldCustom(ctx *models.ExecutionContext, ste
12991265 for key , existingValue := range item .CustomFieldValues {
13001266 customFieldValues [key ] = existingValue
13011267 }
1302- customFieldValues [fieldKey ] = newValue
1268+ customFieldValues [normalized . key ] = newValue
13031269 result , err := as .updateItemFromAction (ctx , map [string ]any {
13041270 "custom_field_values" : customFieldValues ,
13051271 })
13061272 if err != nil {
13071273 return err
13081274 }
13091275 if result .Item .CustomFieldValues != nil {
1310- newValue = result .Item .CustomFieldValues [fieldKey ]
1276+ newValue = result .Item .CustomFieldValues [normalized . key ]
13111277 }
13121278
13131279 key := "custom_field_" + strconv .Itoa (config .CustomFieldID )
@@ -1321,6 +1287,47 @@ func (as *ActionService) executeSetFieldCustom(ctx *models.ExecutionContext, ste
13211287 return nil
13221288}
13231289
1290+ type normalizedActionCustomField struct {
1291+ key string
1292+ value any
1293+ }
1294+
1295+ func (as * ActionService ) normalizeSetFieldCustomValue (customFieldID int , value string ) (normalizedActionCustomField , error ) {
1296+ fieldKey := strconv .Itoa (customFieldID )
1297+ values := map [string ]any {fieldKey : value }
1298+ fieldTypes , err := validation .CustomFieldTypes (as .db , values )
1299+ if err != nil {
1300+ return normalizedActionCustomField {}, fmt .Errorf ("resolve custom field type: %w" , err )
1301+ }
1302+ fieldType , ok := fieldTypes [fieldKey ]
1303+ if ! ok {
1304+ return normalizedActionCustomField {}, fmt .Errorf ("set_field: custom field %d does not exist" , customFieldID )
1305+ }
1306+ switch fieldType {
1307+ case "select" :
1308+ if strings .TrimSpace (value ) == "" {
1309+ values [fieldKey ] = nil
1310+ }
1311+ case "multiselect" :
1312+ values [fieldKey ] = parseActionMultiselectValue (value )
1313+ case models .CustomFieldTypeBoolean , models .CustomFieldTypeCheckbox :
1314+ switch strings .ToLower (strings .TrimSpace (value )) {
1315+ case "" :
1316+ values [fieldKey ] = nil
1317+ case "true" :
1318+ values [fieldKey ] = true
1319+ case "false" :
1320+ values [fieldKey ] = false
1321+ default :
1322+ return normalizedActionCustomField {}, fmt .Errorf ("set_field: custom field %d requires true or false" , customFieldID )
1323+ }
1324+ }
1325+ if err := validation .ValidateAndNormalizeCustomFieldValues (as .db , values ); err != nil {
1326+ return normalizedActionCustomField {}, fmt .Errorf ("set_field: custom field %d: %w" , customFieldID , err )
1327+ }
1328+ return normalizedActionCustomField {key : fieldKey , value : values [fieldKey ]}, nil
1329+ }
1330+
13241331// parseActionMultiselectValue decodes a substituted multiselect set_field
13251332// value: a JSON array ("[1,2]") or a CSV of option ids ("1, 2"). An empty
13261333// string means "clear". Elements stay untyped — option-id coercion and
@@ -1815,20 +1822,10 @@ func (as *ActionService) executeCondition(node *models.ActionNode, ctx *models.E
18151822// evaluateCondition evaluates a condition
18161823func (as * ActionService ) evaluateCondition (value any , operator , compareValue string ) bool {
18171824 strValue := fmt .Sprintf ("%v" , value )
1818-
1825+ if result , handled := evaluateStringActionCondition (strValue , operator , compareValue ); handled {
1826+ return result
1827+ }
18191828 switch operator {
1820- case "eq" , "==" , "equals" :
1821- return strValue == compareValue
1822- case "ne" , "!=" , "not_equals" :
1823- return strValue != compareValue
1824- case "contains" :
1825- return strings .Contains (strValue , compareValue )
1826- case "not_contains" :
1827- return ! strings .Contains (strValue , compareValue )
1828- case "starts_with" :
1829- return strings .HasPrefix (strValue , compareValue )
1830- case "ends_with" :
1831- return strings .HasSuffix (strValue , compareValue )
18321829 case "gt" , ">" :
18331830 return compareNumericOrString (strValue , compareValue , func (a , b float64 ) bool { return a > b }, func (a , b string ) bool { return a > b })
18341831 case "lt" , "<" :
@@ -1837,12 +1834,31 @@ func (as *ActionService) evaluateCondition(value any, operator, compareValue str
18371834 return compareNumericOrString (strValue , compareValue , func (a , b float64 ) bool { return a >= b }, func (a , b string ) bool { return a >= b })
18381835 case "lte" , "<=" :
18391836 return compareNumericOrString (strValue , compareValue , func (a , b float64 ) bool { return a <= b }, func (a , b string ) bool { return a <= b })
1837+ default :
1838+ return false
1839+ }
1840+ }
1841+
1842+ func evaluateStringActionCondition (value , operator , compareValue string ) (matched , handled bool ) {
1843+ switch operator {
1844+ case "eq" , "==" , "equals" :
1845+ return value == compareValue , true
1846+ case "ne" , "!=" , "not_equals" :
1847+ return value != compareValue , true
1848+ case "contains" :
1849+ return strings .Contains (value , compareValue ), true
1850+ case "not_contains" :
1851+ return ! strings .Contains (value , compareValue ), true
1852+ case "starts_with" :
1853+ return strings .HasPrefix (value , compareValue ), true
1854+ case "ends_with" :
1855+ return strings .HasSuffix (value , compareValue ), true
18401856 case "is_empty" :
1841- return strValue == "" || strValue == "null" || strValue == "<nil>"
1857+ return value == "" || value == "null" || value == "<nil>" , true
18421858 case "is_not_empty" :
1843- return strValue != "" && strValue != "null" && strValue != "<nil>"
1859+ return value != "" && value != "null" && value != "<nil>" , true
18441860 default :
1845- return false
1861+ return false , false
18461862 }
18471863}
18481864
0 commit comments