Files
team/server/public/model/property_field.go
David KrauserandGitHub 773b113e9c [MM-66942] Ensure consistent option ID generation across all field creation paths (#34725)
Option IDs are automatically generated for fields in the REST API, but plugins creating fields directly don't get this behavior. This change makes option ID generation consistent by automatically generating IDs for all select/multiselect options, regardless of whether they're created via REST API or plugin code. The option IDs are generated (if necessary) in the store layer right before saving, which is the same place we generate Field IDs if they don't exist.
2026-02-06 16:19:42 -05:00

408 lines
12 KiB
Go

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"unicode/utf8"
)
type PropertyFieldType string
const (
PropertyFieldTypeText PropertyFieldType = "text"
PropertyFieldTypeSelect PropertyFieldType = "select"
PropertyFieldTypeMultiselect PropertyFieldType = "multiselect"
PropertyFieldTypeDate PropertyFieldType = "date"
PropertyFieldTypeUser PropertyFieldType = "user"
PropertyFieldTypeMultiuser PropertyFieldType = "multiuser"
PropertyFieldNameMaxRunes = 255
PropertyFieldTargetIDMaxRunes = 255
PropertyFieldTargetTypeMaxRunes = 255
)
type PropertyField struct {
ID string `json:"id"`
GroupID string `json:"group_id"`
Name string `json:"name"`
Type PropertyFieldType `json:"type"`
Attrs StringInterface `json:"attrs"`
TargetID string `json:"target_id"`
TargetType string `json:"target_type"`
CreateAt int64 `json:"create_at"`
UpdateAt int64 `json:"update_at"`
DeleteAt int64 `json:"delete_at"`
}
func (pf *PropertyField) Auditable() map[string]any {
return map[string]any{
"id": pf.ID,
"group_id": pf.GroupID,
"name": pf.Name,
"type": pf.Type,
"attrs": pf.Attrs,
"target_id": pf.TargetID,
"target_type": pf.TargetType,
"create_at": pf.CreateAt,
"update_at": pf.UpdateAt,
"delete_at": pf.DeleteAt,
}
}
// PreSave will set the Id if missing. It will also fill in the CreateAt, UpdateAt
// times and ensure DeleteAt is 0. It should be run before saving the field to the db.
func (pf *PropertyField) PreSave() {
if pf.ID == "" {
pf.ID = NewId()
}
pf.CreateAt = GetMillis()
pf.UpdateAt = pf.CreateAt
pf.DeleteAt = 0
}
// EnsureOptionIDs generates IDs for any options that don't have them in select/multiselect fields.
// This ensures option IDs are always set, similar to how field IDs are auto-generated.
func (pf *PropertyField) EnsureOptionIDs() error {
if pf.Type != PropertyFieldTypeSelect && pf.Type != PropertyFieldTypeMultiselect {
return nil
}
if pf.Attrs == nil {
return nil
}
optionsRaw, ok := pf.Attrs[PropertyFieldAttributeOptions]
if !ok {
return nil
}
// Normalize with JSON to handle any slice type
optionsBytes, err := json.Marshal(optionsRaw)
if err != nil {
return fmt.Errorf("failed to marshal options for field ID %s: %w", pf.ID, err)
}
var options []map[string]any
if err := json.Unmarshal(optionsBytes, &options); err != nil {
return fmt.Errorf("invalid options format for field ID %s: %w", pf.ID, err)
}
for _, optMap := range options {
if id, ok := optMap["id"].(string); !ok || id == "" {
optMap["id"] = NewId()
}
}
// Convert back to []any to maintain type compatibility
optionsAny := make([]any, len(options))
for i, opt := range options {
optionsAny[i] = opt
}
pf.Attrs[PropertyFieldAttributeOptions] = optionsAny
return nil
}
func (pf *PropertyField) IsValid() error {
if !IsValidId(pf.ID) {
return NewAppError("PropertyField.IsValid", "model.property_field.is_valid.app_error", map[string]any{"FieldName": "id", "Reason": "invalid id"}, "", http.StatusBadRequest)
}
if !IsValidId(pf.GroupID) {
return NewAppError("PropertyField.IsValid", "model.property_field.is_valid.app_error", map[string]any{"FieldName": "group_id", "Reason": "invalid id"}, "id="+pf.ID, http.StatusBadRequest)
}
if pf.Name == "" {
return NewAppError("PropertyField.IsValid", "model.property_field.is_valid.app_error", map[string]any{"FieldName": "name", "Reason": "value cannot be empty"}, "id="+pf.ID, http.StatusBadRequest)
}
if utf8.RuneCountInString(pf.Name) > PropertyFieldNameMaxRunes {
return NewAppError("PropertyField.IsValid", "model.property_field.is_valid.app_error", map[string]any{"FieldName": "name", "Reason": "value exceeds maximum length"}, "id="+pf.ID, http.StatusBadRequest)
}
if utf8.RuneCountInString(pf.TargetType) > PropertyFieldTargetTypeMaxRunes {
return NewAppError("PropertyField.IsValid", "model.property_field.is_valid.app_error", map[string]any{"FieldName": "target_type", "Reason": "value exceeds maximum length"}, "id="+pf.ID, http.StatusBadRequest)
}
if utf8.RuneCountInString(pf.TargetID) > PropertyFieldTargetIDMaxRunes {
return NewAppError("PropertyField.IsValid", "model.property_field.is_valid.app_error", map[string]any{"FieldName": "target_id", "Reason": "value exceeds maximum length"}, "id="+pf.ID, http.StatusBadRequest)
}
if pf.Type != PropertyFieldTypeText &&
pf.Type != PropertyFieldTypeSelect &&
pf.Type != PropertyFieldTypeMultiselect &&
pf.Type != PropertyFieldTypeDate &&
pf.Type != PropertyFieldTypeUser &&
pf.Type != PropertyFieldTypeMultiuser {
return NewAppError("PropertyField.IsValid", "model.property_field.is_valid.app_error", map[string]any{"FieldName": "type", "Reason": "unknown value"}, "id="+pf.ID, http.StatusBadRequest)
}
if pf.CreateAt == 0 {
return NewAppError("PropertyField.IsValid", "model.property_field.is_valid.app_error", map[string]any{"FieldName": "create_at", "Reason": "value cannot be zero"}, "id="+pf.ID, http.StatusBadRequest)
}
if pf.UpdateAt == 0 {
return NewAppError("PropertyField.IsValid", "model.property_field.is_valid.app_error", map[string]any{"FieldName": "update_at", "Reason": "value cannot be zero"}, "id="+pf.ID, http.StatusBadRequest)
}
return nil
}
type PropertyFieldPatch struct {
Name *string `json:"name"`
Type *PropertyFieldType `json:"type"`
Attrs *StringInterface `json:"attrs"`
TargetID *string `json:"target_id"`
TargetType *string `json:"target_type"`
}
func (pfp *PropertyFieldPatch) Auditable() map[string]any {
return map[string]any{
"name": pfp.Name,
"type": pfp.Type,
"attrs": pfp.Attrs,
"target_id": pfp.TargetID,
"target_type": pfp.TargetType,
}
}
func (pfp *PropertyFieldPatch) IsValid() error {
if pfp.Name != nil && *pfp.Name == "" {
return NewAppError("PropertyFieldPatch.IsValid", "model.property_field.is_valid.app_error", map[string]any{"FieldName": "name", "Reason": "value cannot be empty"}, "", http.StatusBadRequest)
}
if pfp.Name != nil && utf8.RuneCountInString(*pfp.Name) > PropertyFieldNameMaxRunes {
return NewAppError("PropertyFieldPatch.IsValid", "model.property_field.is_valid.app_error", map[string]any{"FieldName": "name", "Reason": "value exceeds maximum length"}, "", http.StatusBadRequest)
}
if pfp.TargetType != nil && utf8.RuneCountInString(*pfp.TargetType) > PropertyFieldTargetTypeMaxRunes {
return NewAppError("PropertyFieldPatch.IsValid", "model.property_field.is_valid.app_error", map[string]any{"FieldName": "target_type", "Reason": "value exceeds maximum length"}, "", http.StatusBadRequest)
}
if pfp.TargetID != nil && utf8.RuneCountInString(*pfp.TargetID) > PropertyFieldTargetIDMaxRunes {
return NewAppError("PropertyFieldPatch.IsValid", "model.property_field.is_valid.app_error", map[string]any{"FieldName": "target_id", "Reason": "value exceeds maximum length"}, "", http.StatusBadRequest)
}
if pfp.Type != nil &&
*pfp.Type != PropertyFieldTypeText &&
*pfp.Type != PropertyFieldTypeSelect &&
*pfp.Type != PropertyFieldTypeMultiselect &&
*pfp.Type != PropertyFieldTypeDate &&
*pfp.Type != PropertyFieldTypeUser &&
*pfp.Type != PropertyFieldTypeMultiuser {
return NewAppError("PropertyFieldPatch.IsValid", "model.property_field.is_valid.app_error", map[string]any{"FieldName": "type", "Reason": "unknown value"}, "", http.StatusBadRequest)
}
return nil
}
func (pf *PropertyField) Patch(patch *PropertyFieldPatch) {
if patch.Name != nil {
pf.Name = *patch.Name
}
if patch.Type != nil {
pf.Type = *patch.Type
}
if patch.Attrs != nil {
pf.Attrs = *patch.Attrs
}
if patch.TargetID != nil {
pf.TargetID = *patch.TargetID
}
if patch.TargetType != nil {
pf.TargetType = *patch.TargetType
}
}
type PropertyFieldSearchCursor struct {
PropertyFieldID string
CreateAt int64
}
func (p PropertyFieldSearchCursor) IsEmpty() bool {
return p.PropertyFieldID == "" && p.CreateAt == 0
}
func (p PropertyFieldSearchCursor) IsValid() error {
if p.IsEmpty() {
return nil
}
if p.CreateAt <= 0 {
return errors.New("create at cannot be negative or zero")
}
if !IsValidId(p.PropertyFieldID) {
return errors.New("property field id is invalid")
}
return nil
}
type PropertyFieldSearchOpts struct {
GroupID string
TargetType string
TargetIDs []string
SinceUpdateAt int64 // UpdatedAt after which to send the items
IncludeDeleted bool
Cursor PropertyFieldSearchCursor
PerPage int
}
func (pf *PropertyField) GetAttr(key string) any {
return pf.Attrs[key]
}
const PropertyFieldAttributeOptions = "options"
type PropertyOption interface {
GetID() string
GetName() string
SetID(id string)
IsValid() error
}
type PropertyOptions[T PropertyOption] []T
func NewPropertyOptionsFromFieldAttrs[T PropertyOption](optionsArr any) (PropertyOptions[T], error) {
options := PropertyOptions[T]{}
b, err := json.Marshal(optionsArr)
if err != nil {
return nil, fmt.Errorf("failed to marshal options: %w", err)
}
err = json.Unmarshal(b, &options)
if err != nil {
return nil, fmt.Errorf("failed to unmarshal options: %w", err)
}
for i := range options {
if options[i].GetID() == "" {
options[i].SetID(NewId())
}
}
return options, nil
}
func (p PropertyOptions[T]) IsValid() error {
if len(p) == 0 {
return errors.New("options list cannot be empty")
}
seenNames := make(map[string]struct{})
for i, option := range p {
if err := option.IsValid(); err != nil {
return fmt.Errorf("invalid option at index %d: %w", i, err)
}
if _, exists := seenNames[option.GetName()]; exists {
return fmt.Errorf("duplicate option name found at index %d: %s", i, option.GetName())
}
seenNames[option.GetName()] = struct{}{}
}
return nil
}
// PluginPropertyOption provides a simple implementation of PropertyOption for plugins
// using a map[string]string for flexible key-value storage
type PluginPropertyOption struct {
Data map[string]string `json:"data"`
}
func NewPluginPropertyOption(id, name string) *PluginPropertyOption {
return &PluginPropertyOption{
Data: map[string]string{
"id": id,
"name": name,
},
}
}
func (p *PluginPropertyOption) GetID() string {
if p.Data == nil {
return ""
}
return p.Data["id"]
}
func (p *PluginPropertyOption) GetName() string {
if p.Data == nil {
return ""
}
return p.Data["name"]
}
func (p *PluginPropertyOption) SetID(id string) {
if p.Data == nil {
p.Data = make(map[string]string)
}
p.Data["id"] = id
}
func (p *PluginPropertyOption) IsValid() error {
if p.Data == nil {
return errors.New("data cannot be nil")
}
id := p.GetID()
if id == "" {
return errors.New("id cannot be empty")
}
if !IsValidId(id) {
return errors.New("id is not a valid ID")
}
name := p.GetName()
if name == "" {
return errors.New("name cannot be empty")
}
return nil
}
// GetValue retrieves a custom value from the option data
func (p *PluginPropertyOption) GetValue(key string) string {
if p.Data == nil {
return ""
}
return p.Data[key]
}
// SetValue sets a custom value in the option data
func (p *PluginPropertyOption) SetValue(key, value string) {
if p.Data == nil {
p.Data = make(map[string]string)
}
p.Data[key] = value
}
// MarshalJSON implements custom JSON marshaling to avoid wrapping in "data"
func (p *PluginPropertyOption) MarshalJSON() ([]byte, error) {
if p.Data == nil {
return json.Marshal(map[string]string{})
}
return json.Marshal(p.Data)
}
// UnmarshalJSON implements custom JSON unmarshaling to handle unwrapped JSON
func (p *PluginPropertyOption) UnmarshalJSON(data []byte) error {
var result map[string]string
if err := json.Unmarshal(data, &result); err != nil {
return err
}
p.Data = result
return nil
}