mirror of
https://github.com/hanzoai/team.git
synced 2026-09-30 01:57:35 +00:00
* Updates search opts and cursor for searching property fields * Adds store changes and tests to accomodate the new search options * Add an index for improving delta query efficiency * Enhances the get property fields endpoint to support deltas and hierarchical searches * Adds new fields search endpoint * Adds since to the property values endpoints * Adds property value endpoint documentation * Fix linter * Address coderabbit comments * Complete i18n * Minor improvements * Consistently apply DWIM semantics around object_type system * Add support for DM/GMs * Move auditRec to the top of the searchPropertyFieldsCore method * Update since mechanics to use greater or equal * Update API docs * Updated tests to filter after the semantics changes --------- Co-authored-by: Miguel de la Cruz <[email protected]>
736 lines
27 KiB
Go
736 lines
27 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"
|
|
"slices"
|
|
"unicode/utf8"
|
|
)
|
|
|
|
type PropertyFieldType string
|
|
|
|
// PropertyFieldTargetLevel represents the hierarchy level of a property field.
|
|
// Used both for TargetType field values and for conflict detection results.
|
|
type PropertyFieldTargetLevel string
|
|
|
|
// PermissionLevel represents the access level for property field operations
|
|
type PermissionLevel string
|
|
|
|
const (
|
|
PropertyFieldTypeText PropertyFieldType = "text"
|
|
PropertyFieldTypeSelect PropertyFieldType = "select"
|
|
PropertyFieldTypeMultiselect PropertyFieldType = "multiselect"
|
|
PropertyFieldTypeDate PropertyFieldType = "date"
|
|
PropertyFieldTypeUser PropertyFieldType = "user"
|
|
PropertyFieldTypeMultiuser PropertyFieldType = "multiuser"
|
|
PropertyFieldTypeRank PropertyFieldType = "rank"
|
|
|
|
PropertyFieldNameMaxRunes = 255
|
|
PropertyFieldTargetIDMaxRunes = 255
|
|
PropertyFieldTargetTypeMaxRunes = 255
|
|
PropertyFieldObjectTypeMaxRunes = 255
|
|
|
|
PropertyFieldTargetLevelSystem PropertyFieldTargetLevel = "system"
|
|
PropertyFieldTargetLevelTeam PropertyFieldTargetLevel = "team"
|
|
PropertyFieldTargetLevelChannel PropertyFieldTargetLevel = "channel"
|
|
|
|
PermissionLevelNone PermissionLevel = "none"
|
|
PermissionLevelSysadmin PermissionLevel = "sysadmin"
|
|
PermissionLevelMember PermissionLevel = "member"
|
|
// PermissionLevelAdmin resolves to the admin of the field's target: sysadmin
|
|
// for system targets, team admin for team targets, channel admin for
|
|
// channel targets. The specific permission checked per scope is documented
|
|
// at hasPropertyFieldPermissionLevel in the app package.
|
|
PermissionLevelAdmin PermissionLevel = "admin"
|
|
|
|
PropertyFieldObjectTypePost = "post"
|
|
PropertyFieldObjectTypeChannel = "channel"
|
|
PropertyFieldObjectTypeUser = "user"
|
|
PropertyFieldObjectTypeTemplate = "template"
|
|
PropertyFieldObjectTypeSession = "session"
|
|
|
|
PropertyFieldObjectTypeSystem = "system"
|
|
)
|
|
|
|
// validPermissionLevels contains all valid PermissionLevel values.
|
|
var validPermissionLevels = []PermissionLevel{
|
|
PermissionLevelNone,
|
|
PermissionLevelSysadmin,
|
|
PermissionLevelMember,
|
|
PermissionLevelAdmin,
|
|
}
|
|
|
|
// validPSAv2TargetTypes contains all valid TargetType values for PSAv2 properties.
|
|
var validPSAv2TargetTypes = []string{
|
|
string(PropertyFieldTargetLevelSystem),
|
|
string(PropertyFieldTargetLevelTeam),
|
|
string(PropertyFieldTargetLevelChannel),
|
|
}
|
|
|
|
// validPropertyFieldObjectTypes contains all valid ObjectType values for PSAv2 properties.
|
|
var validPropertyFieldObjectTypes = []string{
|
|
PropertyFieldObjectTypePost,
|
|
PropertyFieldObjectTypeChannel,
|
|
PropertyFieldObjectTypeUser,
|
|
PropertyFieldObjectTypeTemplate,
|
|
PropertyFieldObjectTypeSession,
|
|
PropertyFieldObjectTypeSystem,
|
|
}
|
|
|
|
// optionFieldTypes are the property field types whose `options` attribute is
|
|
// meaningful: each stores a list of selectable options. Kept as a single
|
|
// allow-list so the "does this field carry options?" check stays consistent
|
|
// across the model, API, and access-control layers.
|
|
var optionFieldTypes = []PropertyFieldType{
|
|
PropertyFieldTypeSelect,
|
|
PropertyFieldTypeMultiselect,
|
|
PropertyFieldTypeRank,
|
|
}
|
|
|
|
// SupportsOptions reports whether the field type carries a list of options
|
|
// (select, multiselect, rank). Mirrors the webapp's supportsOptions helper.
|
|
func (t PropertyFieldType) SupportsOptions() bool {
|
|
return slices.Contains(optionFieldTypes, t)
|
|
}
|
|
|
|
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"`
|
|
ObjectType string `json:"object_type"`
|
|
Protected bool `json:"protected"`
|
|
PermissionField *PermissionLevel `json:"permission_field,omitempty"`
|
|
PermissionValues *PermissionLevel `json:"permission_values,omitempty"`
|
|
PermissionOptions *PermissionLevel `json:"permission_options,omitempty"`
|
|
LinkedFieldID *string `json:"linked_field_id,omitempty"`
|
|
CreateAt int64 `json:"create_at"`
|
|
UpdateAt int64 `json:"update_at"`
|
|
DeleteAt int64 `json:"delete_at"`
|
|
CreatedBy string `json:"created_by"`
|
|
UpdatedBy string `json:"updated_by"`
|
|
}
|
|
|
|
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,
|
|
"object_type": pf.ObjectType,
|
|
"protected": pf.Protected,
|
|
"permission_field": pf.PermissionField,
|
|
"permission_values": pf.PermissionValues,
|
|
"permission_options": pf.PermissionOptions,
|
|
"linked_field_id": pf.LinkedFieldID,
|
|
"create_at": pf.CreateAt,
|
|
"update_at": pf.UpdateAt,
|
|
"delete_at": pf.DeleteAt,
|
|
"created_by": pf.CreatedBy,
|
|
"updated_by": pf.UpdatedBy,
|
|
}
|
|
}
|
|
|
|
// 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.SupportsOptions() {
|
|
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 utf8.RuneCountInString(pf.ObjectType) > PropertyFieldObjectTypeMaxRunes {
|
|
return NewAppError("PropertyField.IsValid", "model.property_field.is_valid.app_error", map[string]any{"FieldName": "object_type", "Reason": "value exceeds maximum length"}, "id="+pf.ID, http.StatusBadRequest)
|
|
}
|
|
|
|
// PSAv2-specific validations: ObjectType, TargetType, and TargetType/TargetID consistency
|
|
if pf.IsPSAv2() {
|
|
if !IsValidPropertyFieldObjectType(pf.ObjectType) {
|
|
return NewAppError("PropertyField.IsValid", "model.property_field.is_valid.app_error", map[string]any{"FieldName": "object_type", "Reason": "unknown value"}, "id="+pf.ID, http.StatusBadRequest)
|
|
}
|
|
|
|
if !IsValidPSAv2PropertyFieldTargetType(pf.TargetType) {
|
|
return NewAppError("PropertyField.IsValid", "model.property_field.is_valid.app_error", map[string]any{"FieldName": "target_type", "Reason": "unknown value"}, "id="+pf.ID, http.StatusBadRequest)
|
|
}
|
|
|
|
switch pf.TargetType {
|
|
case string(PropertyFieldTargetLevelSystem):
|
|
if pf.TargetID != "" {
|
|
return NewAppError("PropertyField.IsValid", "model.property_field.is_valid.app_error", map[string]any{"FieldName": "target_id", "Reason": "must be empty for system target type"}, "id="+pf.ID, http.StatusBadRequest)
|
|
}
|
|
case string(PropertyFieldTargetLevelTeam), string(PropertyFieldTargetLevelChannel):
|
|
if !IsValidId(pf.TargetID) {
|
|
return NewAppError("PropertyField.IsValid", "model.property_field.is_valid.app_error", map[string]any{"FieldName": "target_id", "Reason": "must be a valid ID for team or channel target type"}, "id="+pf.ID, http.StatusBadRequest)
|
|
}
|
|
}
|
|
|
|
// System-object fields attach to the system itself; they cannot be scoped below the system level.
|
|
if pf.ObjectType == PropertyFieldObjectTypeSystem && pf.TargetType != string(PropertyFieldTargetLevelSystem) {
|
|
return NewAppError("PropertyField.IsValid", "model.property_field.is_valid.app_error", map[string]any{"FieldName": "target_type", "Reason": "must be system for system object type"}, "id="+pf.ID, http.StatusBadRequest)
|
|
}
|
|
} else {
|
|
// PSAv1 properties cannot have permissions or be protected
|
|
if pf.Protected {
|
|
return NewAppError("PropertyField.IsValid", "model.property_field.is_valid.app_error", map[string]any{"FieldName": "protected", "Reason": "PSAv1 properties cannot be protected"}, "id="+pf.ID, http.StatusBadRequest)
|
|
}
|
|
|
|
if pf.PermissionField != nil {
|
|
return NewAppError("PropertyField.IsValid", "model.property_field.is_valid.app_error", map[string]any{"FieldName": "permission_field", "Reason": "PSAv1 properties cannot have permissions"}, "id="+pf.ID, http.StatusBadRequest)
|
|
}
|
|
|
|
if pf.PermissionValues != nil {
|
|
return NewAppError("PropertyField.IsValid", "model.property_field.is_valid.app_error", map[string]any{"FieldName": "permission_values", "Reason": "PSAv1 properties cannot have permissions"}, "id="+pf.ID, http.StatusBadRequest)
|
|
}
|
|
|
|
if pf.PermissionOptions != nil {
|
|
return NewAppError("PropertyField.IsValid", "model.property_field.is_valid.app_error", map[string]any{"FieldName": "permission_options", "Reason": "PSAv1 properties cannot have permissions"}, "id="+pf.ID, http.StatusBadRequest)
|
|
}
|
|
}
|
|
|
|
if pf.Type != PropertyFieldTypeText &&
|
|
pf.Type != PropertyFieldTypeSelect &&
|
|
pf.Type != PropertyFieldTypeMultiselect &&
|
|
pf.Type != PropertyFieldTypeDate &&
|
|
pf.Type != PropertyFieldTypeUser &&
|
|
pf.Type != PropertyFieldTypeMultiuser &&
|
|
pf.Type != PropertyFieldTypeRank {
|
|
return NewAppError("PropertyField.IsValid", "model.property_field.is_valid.app_error", map[string]any{"FieldName": "type", "Reason": "unknown value"}, "id="+pf.ID, http.StatusBadRequest)
|
|
}
|
|
|
|
// LinkedFieldID validation: if set, must be a valid 26-char ID.
|
|
// Empty string is allowed as a transient signal for unlinking; callers
|
|
// must canonicalize it to nil before persistence.
|
|
if pf.LinkedFieldID != nil && *pf.LinkedFieldID != "" && !IsValidId(*pf.LinkedFieldID) {
|
|
return NewAppError("PropertyField.IsValid", "model.property_field.is_valid.app_error", map[string]any{"FieldName": "linked_field_id", "Reason": "invalid id"}, "id="+pf.ID, http.StatusBadRequest)
|
|
}
|
|
|
|
// Template fields are canonical schema definitions and must not link to other fields
|
|
if pf.ObjectType == PropertyFieldObjectTypeTemplate && pf.LinkedFieldID != nil && *pf.LinkedFieldID != "" {
|
|
return NewAppError("PropertyField.IsValid", "model.property_field.is_valid.app_error", map[string]any{"FieldName": "linked_field_id", "Reason": "template fields cannot have a linked field"}, "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)
|
|
}
|
|
|
|
if pf.PermissionField != nil && !slices.Contains(validPermissionLevels, *pf.PermissionField) {
|
|
return NewAppError("PropertyField.IsValid", "model.property_field.is_valid.app_error", map[string]any{"FieldName": "permission_field", "Reason": "invalid permission level"}, "id="+pf.ID, http.StatusBadRequest)
|
|
}
|
|
|
|
if pf.PermissionValues != nil && !slices.Contains(validPermissionLevels, *pf.PermissionValues) {
|
|
return NewAppError("PropertyField.IsValid", "model.property_field.is_valid.app_error", map[string]any{"FieldName": "permission_values", "Reason": "invalid permission level"}, "id="+pf.ID, http.StatusBadRequest)
|
|
}
|
|
|
|
if pf.PermissionOptions != nil && !slices.Contains(validPermissionLevels, *pf.PermissionOptions) {
|
|
return NewAppError("PropertyField.IsValid", "model.property_field.is_valid.app_error", map[string]any{"FieldName": "permission_options", "Reason": "invalid permission level"}, "id="+pf.ID, http.StatusBadRequest)
|
|
}
|
|
|
|
// Cross-validation: protected fields must have field permission set to "none"
|
|
if pf.Protected {
|
|
if pf.PermissionField == nil {
|
|
return NewAppError("PropertyField.IsValid", "model.property_field.is_valid.app_error", map[string]any{"FieldName": "permission_field", "Reason": "protected fields must have explicit permissions with field set to none"}, "id="+pf.ID, http.StatusBadRequest)
|
|
}
|
|
if *pf.PermissionField != PermissionLevelNone {
|
|
return NewAppError("PropertyField.IsValid", "model.property_field.is_valid.app_error", map[string]any{"FieldName": "permission_field", "Reason": "protected fields must have field permission set to none"}, "id="+pf.ID, http.StatusBadRequest)
|
|
}
|
|
}
|
|
|
|
// Cross-validation: non-protected fields cannot have field permission set to "none"
|
|
if !pf.Protected && pf.PermissionField != nil && *pf.PermissionField == PermissionLevelNone {
|
|
return NewAppError("PropertyField.IsValid", "model.property_field.is_valid.app_error", map[string]any{"FieldName": "permission_field", "Reason": "non-protected fields cannot have field permission set to none"}, "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"`
|
|
LinkedFieldID *string `json:"linked_field_id,omitempty"`
|
|
}
|
|
|
|
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,
|
|
"linked_field_id": pfp.LinkedFieldID,
|
|
}
|
|
}
|
|
|
|
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 &&
|
|
*pfp.Type != PropertyFieldTypeRank {
|
|
return NewAppError("PropertyFieldPatch.IsValid", "model.property_field.is_valid.app_error", map[string]any{"FieldName": "type", "Reason": "unknown value"}, "", http.StatusBadRequest)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Patch applies a PropertyFieldPatch to the field. When mergeAttrs is true,
|
|
// only the keys present in the patch are updated in Attrs, with nil values
|
|
// deleting keys. When false, Attrs is replaced wholesale.
|
|
func (pf *PropertyField) Patch(patch *PropertyFieldPatch, mergeAttrs bool) {
|
|
if patch.Name != nil {
|
|
pf.Name = *patch.Name
|
|
}
|
|
|
|
if patch.Type != nil {
|
|
pf.Type = *patch.Type
|
|
}
|
|
|
|
if patch.Attrs != nil {
|
|
if mergeAttrs {
|
|
if pf.Attrs == nil {
|
|
pf.Attrs = make(StringInterface)
|
|
}
|
|
for key, value := range *patch.Attrs {
|
|
if value == nil {
|
|
delete(pf.Attrs, key)
|
|
} else {
|
|
pf.Attrs[key] = value
|
|
}
|
|
}
|
|
} else {
|
|
pf.Attrs = *patch.Attrs
|
|
}
|
|
}
|
|
|
|
if patch.TargetID != nil {
|
|
pf.TargetID = *patch.TargetID
|
|
}
|
|
|
|
if patch.TargetType != nil {
|
|
pf.TargetType = *patch.TargetType
|
|
}
|
|
|
|
if patch.LinkedFieldID != nil {
|
|
if *patch.LinkedFieldID == "" {
|
|
// Empty string means unlink — clear to NULL
|
|
pf.LinkedFieldID = nil
|
|
} else {
|
|
pf.LinkedFieldID = patch.LinkedFieldID
|
|
}
|
|
}
|
|
}
|
|
|
|
// IsPSAv1 returns true if this property field uses the legacy PSAv1 schema.
|
|
// Legacy properties have an empty ObjectType and rely on simple TargetID uniqueness
|
|
// enforced by the idx_propertyfields_unique_legacy database constraint, rather than
|
|
// the hierarchical uniqueness model used by PSAv2 (ObjectType-based) properties.
|
|
func (pf *PropertyField) IsPSAv1() bool {
|
|
return pf.ObjectType == ""
|
|
}
|
|
|
|
// IsPSAv2 returns true if this property field uses the PSAv2 schema.
|
|
// PSAv2 properties have a non-empty ObjectType and use hierarchical
|
|
// uniqueness based on ObjectType, TargetType, and TargetID.
|
|
func (pf *PropertyField) IsPSAv2() bool {
|
|
return pf.ObjectType != ""
|
|
}
|
|
|
|
// IsValidPSAv2PropertyFieldTargetType checks if the given TargetType string is a valid
|
|
// PSAv2 target level
|
|
func IsValidPSAv2PropertyFieldTargetType(targetType string) bool {
|
|
return slices.Contains(validPSAv2TargetTypes, targetType)
|
|
}
|
|
|
|
// IsValidPropertyFieldObjectType checks if the given ObjectType string is a valid
|
|
// property field object type
|
|
func IsValidPropertyFieldObjectType(objectType string) bool {
|
|
return slices.Contains(validPropertyFieldObjectTypes, objectType)
|
|
}
|
|
|
|
// PropertyFieldSearchCursor carries two alternative pagination keys because
|
|
// field listings serve two different read patterns:
|
|
//
|
|
// - Directory listings (no since filter) page in creation order using
|
|
// CreateAt + PropertyFieldID. CreateAt never changes, so the scan is
|
|
// stable across concurrent patches.
|
|
// - Delta sync (SinceUpdateAt > 0) pages in update order using UpdateAt +
|
|
// PropertyFieldID, matching the ORDER BY the store applies in that mode.
|
|
//
|
|
// IsValid requires exactly one of CreateAt or UpdateAt to be positive
|
|
// alongside a valid PropertyFieldID. An empty cursor is also valid and means
|
|
// "start from the beginning".
|
|
type PropertyFieldSearchCursor struct {
|
|
PropertyFieldID string
|
|
CreateAt int64
|
|
UpdateAt int64
|
|
}
|
|
|
|
func (p PropertyFieldSearchCursor) IsEmpty() bool {
|
|
return p.PropertyFieldID == "" && p.CreateAt == 0 && p.UpdateAt == 0
|
|
}
|
|
|
|
func (p PropertyFieldSearchCursor) IsValid() error {
|
|
if p.IsEmpty() {
|
|
return nil
|
|
}
|
|
|
|
if !IsValidId(p.PropertyFieldID) {
|
|
return errors.New("property field id is invalid")
|
|
}
|
|
|
|
hasCreate := p.CreateAt > 0
|
|
hasUpdate := p.UpdateAt > 0
|
|
if hasCreate == hasUpdate {
|
|
return errors.New("cursor must have exactly one of create_at or update_at set")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// PropertyFieldSearch captures the parameters provided by a client for
|
|
// searching property fields.
|
|
//
|
|
// Scope is specified one of two ways (mutually exclusive):
|
|
// - Hierarchical: ChannelID and/or TeamID — returns rows at the named scope
|
|
// plus every ancestor above it.
|
|
// - Single-target: TargetType + TargetID — returns rows for exactly one
|
|
// resource.
|
|
//
|
|
// SinceUpdateAt > 0 switches the endpoint to delta mode: rows are ordered by
|
|
// update_at, tombstones are included, and pagination must use CursorUpdateAt
|
|
// (CursorCreateAt is used in the default directory mode).
|
|
type PropertyFieldSearch struct {
|
|
ObjectTypes []string `json:"object_types,omitempty"`
|
|
TargetType string `json:"target_type,omitempty"`
|
|
TargetID string `json:"target_id,omitempty"`
|
|
ChannelID string `json:"channel_id,omitempty"`
|
|
TeamID string `json:"team_id,omitempty"`
|
|
SinceUpdateAt int64 `json:"since,omitempty"`
|
|
CursorID string `json:"cursor_id,omitempty"`
|
|
CursorCreateAt int64 `json:"cursor_create_at,omitempty"`
|
|
CursorUpdateAt int64 `json:"cursor_update_at,omitempty"`
|
|
PerPage int `json:"per_page"`
|
|
}
|
|
|
|
// PropertyFieldSearchOpts captures the filters accepted by SearchPropertyFields.
|
|
//
|
|
// Invariants enforced by IsValid:
|
|
// - ObjectType and ObjectTypes are mutually exclusive.
|
|
// - Every entry in ObjectTypes must be a valid PSAv2 object type.
|
|
// - ChannelID/TeamID and TargetType/TargetIDs are mutually exclusive scope modes.
|
|
// - ChannelID requires TeamID (callers must resolve TeamID before search).
|
|
// - SinceUpdateAt <= 0 means "no filter".
|
|
type PropertyFieldSearchOpts struct {
|
|
GroupID string
|
|
// Deprecated: use ObjectTypes instead. Kept for backwards compatibility
|
|
// with existing callers; mutually exclusive with ObjectTypes.
|
|
ObjectType string
|
|
ObjectTypes []string
|
|
TargetType string
|
|
TargetIDs []string
|
|
ChannelID string
|
|
TeamID string
|
|
LinkedFieldID string
|
|
SinceUpdateAt int64
|
|
IncludeDeleted bool
|
|
Cursor PropertyFieldSearchCursor
|
|
PerPage int
|
|
}
|
|
|
|
// IsValid runs the cross-field invariants documented on PropertyFieldSearchOpts.
|
|
func (o PropertyFieldSearchOpts) IsValid() error {
|
|
if o.ObjectType != "" && len(o.ObjectTypes) > 0 {
|
|
return errors.New("object_type and object_types are mutually exclusive")
|
|
}
|
|
|
|
if o.ObjectType != "" && !IsValidPropertyFieldObjectType(o.ObjectType) {
|
|
return fmt.Errorf("invalid object_type %q", o.ObjectType)
|
|
}
|
|
|
|
for _, ot := range o.ObjectTypes {
|
|
if !IsValidPropertyFieldObjectType(ot) {
|
|
return fmt.Errorf("invalid object_type %q", ot)
|
|
}
|
|
}
|
|
|
|
scopeByChanTeam := o.ChannelID != "" || o.TeamID != ""
|
|
scopeByTarget := o.TargetType != "" || len(o.TargetIDs) > 0
|
|
if scopeByChanTeam && scopeByTarget {
|
|
return errors.New("channel_id/team_id cannot be combined with target_type/target_id")
|
|
}
|
|
|
|
if err := o.Cursor.IsValid(); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Cursor key must match the active ordering: delta mode (SinceUpdateAt>0)
|
|
// pages by UpdateAt; directory mode pages by CreateAt. A mismatch would
|
|
// silently skip rows because the WHERE clause references the wrong column.
|
|
if !o.Cursor.IsEmpty() {
|
|
deltaMode := o.SinceUpdateAt > 0
|
|
if deltaMode && o.Cursor.UpdateAt == 0 {
|
|
return errors.New("cursor_update_at required when since is set")
|
|
}
|
|
if !deltaMode && o.Cursor.CreateAt == 0 {
|
|
return errors.New("cursor_create_at required when since is not set")
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
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
|
|
}
|