Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,12 @@ func readOutboundContactList(ctx context.Context, d *schema.ResourceData, meta i
return retry.NonRetryableError(util.BuildWithRetriesApiDiagnosticError(ResourceType, fmt.Sprintf("failed to read Outbound Contact List %s | error: %s", d.Id(), getErr), resp))
}

// The SDK model for phone/email columns does not include the API-returned `*TimeColumnName` fields.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You should probably include in this comment a link or JIRA to the corresponding fix for pulling this field into the SDK with instructions to remove this code after the SDK has been upgraded with support for the new field.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've now added a TODO with a link to the Go SDK repo to remove this once the SDK models include TimeColumnName fields.

// Parse the raw response to preserve timezone column names and prevent UI-save drift.
// TODO: Remove once the Go SDK models include callableTimeColumnName/contactableTimeColumnName:
// https://github.com/MyPureCloud/platform-client-sdk-go
phoneTzIdx, emailTzIdx := parseOutboundContactListRaw(resp.RawBody)

if sdkContactList.Name != nil {
_ = d.Set("name", *sdkContactList.Name)
}
Expand All @@ -180,10 +186,10 @@ func readOutboundContactList(ctx context.Context, d *schema.ResourceData, meta i
_ = d.Set("column_names", *sdkContactList.ColumnNames)
}
if sdkContactList.PhoneColumns != nil {
_ = d.Set("phone_columns", flattenSdkOutboundContactListContactPhoneNumberColumnSlice(*sdkContactList.PhoneColumns))
_ = d.Set("phone_columns", flattenSdkOutboundContactListContactPhoneNumberColumnSlice(*sdkContactList.PhoneColumns, phoneTzIdx))
}
if sdkContactList.EmailColumns != nil {
_ = d.Set("email_columns", flattenSdkOutboundContactListContactEmailAddressColumnSlice(*sdkContactList.EmailColumns))
_ = d.Set("email_columns", flattenSdkOutboundContactListContactEmailAddressColumnSlice(*sdkContactList.EmailColumns, emailTzIdx))
}
if sdkContactList.PreviewModeColumnName != nil {
_ = d.Set("preview_mode_column_name", *sdkContactList.PreviewModeColumnName)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ package outbound_contact_list
// @description: Manages outbound campaign operations including automated voice dialing, SMS/email messaging campaigns, contact list management, and campaign rules for proactive customer outreach.

import (
"context"
"fmt"

"github.com/mypurecloud/terraform-provider-genesyscloud/genesyscloud/provider"
resourceExporter "github.com/mypurecloud/terraform-provider-genesyscloud/genesyscloud/resource_exporter"
"github.com/mypurecloud/terraform-provider-genesyscloud/genesyscloud/validators"
Expand All @@ -14,6 +17,84 @@ import (
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation"
)

func normalizeOutboundContactListTimeColumnFields(_ context.Context, diff *schema.ResourceDiff, _ interface{}) error {
// Normalize plan-time values so the deprecated `*_time_column` and new `*_time_column_name`
// attributes don't cause TypeSet element mismatches/diffs during migration.
if v := diff.Get("phone_columns"); v != nil {
if s, ok := v.(*schema.Set); ok && s.Len() > 0 {
newSet := schema.NewSet(hashOutboundContactListPhoneColumn, []interface{}{})
for _, item := range s.List() {
m, ok := item.(map[string]interface{})
if !ok {
continue
}
if newName, _ := m["callable_time_column_name"].(string); newName == "" {
if oldName, _ := m["callable_time_column"].(string); oldName != "" {
m["callable_time_column_name"] = oldName
}
}
newSet.Add(m)
}
_ = diff.SetNew("phone_columns", newSet)
}
}

if v := diff.Get("email_columns"); v != nil {
if s, ok := v.(*schema.Set); ok && s.Len() > 0 {
newSet := schema.NewSet(hashOutboundContactListEmailColumn, []interface{}{})
for _, item := range s.List() {
m, ok := item.(map[string]interface{})
if !ok {
continue
}
if newName, _ := m["contactable_time_column_name"].(string); newName == "" {
if oldName, _ := m["contactable_time_column"].(string); oldName != "" {
m["contactable_time_column_name"] = oldName
}
}
newSet.Add(m)
}
_ = diff.SetNew("email_columns", newSet)
}
}

return nil
}

func hashOutboundContactListPhoneColumn(v interface{}) int {
m, ok := v.(map[string]interface{})
if !ok {
return 0
}

columnName, _ := m["column_name"].(string)
colType, _ := m["type"].(string)

timeColName, _ := m["callable_time_column_name"].(string)
if timeColName == "" {
timeColName, _ = m["callable_time_column"].(string)
}

return schema.HashString(fmt.Sprintf("%s|%s|%s", columnName, colType, timeColName))
}

func hashOutboundContactListEmailColumn(v interface{}) int {
m, ok := v.(map[string]interface{})
if !ok {
return 0
}

columnName, _ := m["column_name"].(string)
colType, _ := m["type"].(string)

timeColName, _ := m["contactable_time_column_name"].(string)
if timeColName == "" {
timeColName, _ = m["contactable_time_column"].(string)
}

return schema.HashString(fmt.Sprintf("%s|%s|%s", columnName, colType, timeColName))
}

/*
resource_genesycloud_outbound_contact_list_schema.go holds three functions within it:

Expand All @@ -36,9 +117,24 @@ var (
Type: schema.TypeString,
},
`callable_time_column`: {
Description: `A column that indicates the timezone to use for a given contact when checking callable times. Not allowed if 'automaticTimeZoneMapping' is set to true.`,
Optional: true,
Type: schema.TypeString,
Description: `A column that indicates the timezone to use for a given contact when checking callable times. Not allowed if 'automaticTimeZoneMapping' is set to true.`,
Deprecated: "Use `callable_time_column_name` instead.",
Optional: true,
Type: schema.TypeString,
DiffSuppressFunc: func(k, old, new string, d *schema.ResourceData) bool {
// When automatic timezone mapping is enabled, the API may drop callable time columns.
// Suppress diffs to prevent perpetual drift.
return d.Get("automatic_time_zone_mapping").(bool)
},
},
`callable_time_column_name`: {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So, I don't know if its absolutely necessary to add a new field to the schema. Basically, there are two approaches you can take:

  1. Add a new field to the schema that matches the name of the new API field, with a deprecation message (what you have implemented in this PR) plus adding a migration path (see the routing_queue resource for how a migration was handled in the past)
  • Pros: Keeps the provider fields 1:1 aligned with the API fields
  • Cons: Requires the user to make changes/modifications to their Terraform code / output via a code migration.
  1. Retain the callable_time_column field. Update the flatten/build functions to read/write to the callable_time_column_name field for this schema field.
  • Pros: Seamless experience from the user's perspective. No migrations or updates to their code necessary.
  • Cons: Deviates from the API so that the field names don't match up 1:1 (callable_time_column does not match callableTimeColumnName). A comment on the description of the field could clarify this aspect for the user.

You should check in with your team leads on which approach is desired.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After discussing with Hemanth we determined that Approach 1 was desired: I've added the new _time_column_name fields, deprecated the old _time_column fields, and provided a migration path via StateUpgraders using the routing_queue resource as reference.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes this wont break anything for the end user immediately, (thanks to the deprecation) and we can remove this in the later releases. api fields 1:1 aligning with the terraform schema will make sure of consistency in the long run.

Description: `A column name that indicates the timezone to use for a given contact when checking callable times.`,
Optional: true,
Computed: true,
Type: schema.TypeString,
DiffSuppressFunc: func(k, old, new string, d *schema.ResourceData) bool {
return d.Get("automatic_time_zone_mapping").(bool)
},
},
},
}
Expand All @@ -56,9 +152,16 @@ var (
Type: schema.TypeString,
},
`contactable_time_column`: {
Description: `A column that indicates the timezone to use for a given contact when checking contactable times.`,
Optional: true,
Type: schema.TypeString,
Description: `A column that indicates the timezone to use for a given contact when checking contactable times.`,
Deprecated: "Use `contactable_time_column_name` instead.",
Optional: true,
Type: schema.TypeString,
},
`contactable_time_column_name`: {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as above comment for this field.

Description: `A column name that indicates the timezone to use for a given contact when checking contactable times.`,
Optional: true,
Computed: true,
Type: schema.TypeString,
},
},
}
Expand Down Expand Up @@ -107,10 +210,18 @@ func ResourceOutboundContactList() *schema.Resource {
Importer: &schema.ResourceImporter{
StateContext: schema.ImportStatePassthroughContext,
},
SchemaVersion: 2,
SchemaVersion: 3,
StateUpgraders: []schema.StateUpgrader{
{
Version: 2,
Type: resourceOutboundContactListV2().CoreConfigSchema().ImpliedType(),
Upgrade: stateUpgraderOutboundContactListV2ToV3,
},
},
CustomizeDiff: customdiff.All(
customdiff.ComputedIf("contacts_file_content_hash", validators.ValidateFileContentHashChanged("contacts_filepath", "contacts_file_content_hash", S3Enabled)),
validators.ValidateCSVWithColumns("contacts_filepath", "column_names"),
normalizeOutboundContactListTimeColumnFields,
),
Schema: map[string]*schema.Schema{
`name`: {
Expand All @@ -136,13 +247,15 @@ func ResourceOutboundContactList() *schema.Resource {
Optional: true,
ForceNew: true,
Type: schema.TypeSet,
Set: hashOutboundContactListPhoneColumn,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you hash these values, how is the provider supposed to read the values back?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed set behavior so hashing is only for stable TypeSet identity, and ensured flatten/build keep state consistent:

  • Flatten now uses the same hash function as the schema Set: function.
  • Flatten writes both legacy and new time_column fields so config/state don’t drift and match correctly.

Elem: outboundContactListContactPhoneNumberColumnResource,
},
`email_columns`: {
Description: `Indicates which columns are email addresses. Changing the email_columns attribute will cause the outbound_contact_list object to be dropped and recreated with a new ID. Required if phone_columns is empty`,
Optional: true,
ForceNew: true,
Type: schema.TypeSet,
Set: hashOutboundContactListEmailColumn,
Elem: outboundContactListEmailColumnResource,
},
`preview_mode_column_name`: {
Expand Down Expand Up @@ -251,3 +364,158 @@ func DataSourceOutboundContactList() *schema.Resource {
},
}
}

func resourceOutboundContactListV2() *schema.Resource {
outboundContactListContactPhoneNumberColumnResourceV2 := &schema.Resource{
Schema: map[string]*schema.Schema{
`column_name`: {
Required: true,
Type: schema.TypeString,
},
`type`: {
Required: true,
Type: schema.TypeString,
},
`callable_time_column`: {
Optional: true,
Type: schema.TypeString,
},
},
}

outboundContactListEmailColumnResourceV2 := &schema.Resource{
Schema: map[string]*schema.Schema{
`column_name`: {
Required: true,
Type: schema.TypeString,
},
`type`: {
Required: true,
Type: schema.TypeString,
},
`contactable_time_column`: {
Optional: true,
Type: schema.TypeString,
},
},
}

return &schema.Resource{
SchemaVersion: 2,
Schema: map[string]*schema.Schema{
`name`: {
Required: true,
Type: schema.TypeString,
},
`division_id`: {
Optional: true,
Computed: true,
Type: schema.TypeString,
},
`column_names`: {
Required: true,
ForceNew: true,
Type: schema.TypeList,
Elem: &schema.Schema{Type: schema.TypeString},
},
`phone_columns`: {
Optional: true,
ForceNew: true,
Type: schema.TypeSet,
Elem: outboundContactListContactPhoneNumberColumnResourceV2,
},
`email_columns`: {
Optional: true,
ForceNew: true,
Type: schema.TypeSet,
Elem: outboundContactListEmailColumnResourceV2,
},
`preview_mode_column_name`: {
Optional: true,
Type: schema.TypeString,
},
`preview_mode_accepted_values`: {
Optional: true,
Type: schema.TypeList,
Elem: &schema.Schema{Type: schema.TypeString},
},
`attempt_limit_id`: {
Optional: true,
Type: schema.TypeString,
},
`automatic_time_zone_mapping`: {
Optional: true,
ForceNew: true,
Type: schema.TypeBool,
},
`zip_code_column_name`: {
Optional: true,
ForceNew: true,
Type: schema.TypeString,
},
`column_data_type_specifications`: {
Optional: true,
ForceNew: true,
Type: schema.TypeList,
Elem: outboundContactListColumnDataTypeSpecification,
},
`trim_whitespace`: {
Optional: true,
Type: schema.TypeBool,
},
`contacts_filepath`: {
Optional: true,
ForceNew: false,
Type: schema.TypeString,
ValidateFunc: validators.ValidatePath,
RequiredWith: []string{"contacts_filepath", "contacts_id_name"},
},
`contacts_id_name`: {
Optional: true,
ForceNew: false,
Type: schema.TypeString,
RequiredWith: []string{"contacts_id_name", "contacts_filepath"},
},
`contacts_file_content_hash`: {
Computed: true,
Type: schema.TypeString,
},
`contacts_record_count`: {
Computed: true,
Type: schema.TypeInt,
},
},
}
}

func stateUpgraderOutboundContactListV2ToV3(_ context.Context, rawState map[string]interface{}, _ interface{}) (map[string]interface{}, error) {
migrateSet := func(v interface{}, legacyKey, nameKey string) {
list, ok := v.([]interface{})
if !ok {
return
}
for _, item := range list {
m, ok := item.(map[string]interface{})
if !ok {
continue
}
legacy, _ := m[legacyKey].(string)
name, _ := m[nameKey].(string)
if name == "" && legacy != "" {
m[nameKey] = legacy
}
if legacy == "" && name != "" {
m[legacyKey] = name
}
}
}

if v, ok := rawState["phone_columns"]; ok {
migrateSet(v, "callable_time_column", "callable_time_column_name")
}
if v, ok := rawState["email_columns"]; ok {
migrateSet(v, "contactable_time_column", "contactable_time_column_name")
}

return rawState, nil
}
Loading