diff --git a/backend/app/services.go b/backend/app/services.go index 794ba0c..2049dac 100644 --- a/backend/app/services.go +++ b/backend/app/services.go @@ -122,6 +122,7 @@ func NewServices( CampaignRepository: repositories.Campaign, CampaignRecipientRepository: repositories.CampaignRecipient, } + recipientGroup := &service.RecipientGroup{ Common: common, CampaignRepository: repositories.Campaign, diff --git a/backend/database/recipientGroup.go b/backend/database/recipientGroup.go index 20aa78e..8b24bca 100644 --- a/backend/database/recipientGroup.go +++ b/backend/database/recipientGroup.go @@ -11,7 +11,7 @@ const ( RECIPIENT_GROUP_TABLE = "recipient_groups" ) -// RecipientGroup is a grouping of recipient +// RecipientGroup is a grouping of recipients type RecipientGroup struct { ID *uuid.UUID `gorm:"primary_key;not null;unique;type:uuid"` CreatedAt *time.Time `gorm:"not null;index;"` @@ -19,11 +19,18 @@ type RecipientGroup struct { Name string `gorm:"not null;index;uniqueIndex:idx_recipient_groups_unique_name_and_company_id;"` + // IsDynamic indicates that members are resolved at query time via FilterField/FilterValue + IsDynamic bool `gorm:"not null;default:false;"` + // FilterField is the recipient attribute to filter on (position, department, city, country, misc) + FilterField string `gorm:"not null;default:'';"` + // FilterValue is the value to match against the filter field + FilterValue string `gorm:"not null;default:'';"` + // can belong-to CompanyID *uuid.UUID `gorm:"type:uuid;index;uniqueIndex:idx_recipient_groups_unique_name_and_company_id"` Company *Company - // many-to-many + // many-to-many (only used when IsDynamic is false) Recipients []Recipient `gorm:"many2many:recipient_group_recipients;"` } diff --git a/backend/model/recipientGroup.go b/backend/model/recipientGroup.go index 6db59d0..173f87c 100644 --- a/backend/model/recipientGroup.go +++ b/backend/model/recipientGroup.go @@ -1,6 +1,7 @@ package model import ( + "strings" "time" "github.com/google/uuid" @@ -12,6 +13,15 @@ import ( const RECIPIENT_COUNT_NOT_LOADED = int64(-1) const RECIPIENT_GROUP_COUNT_NOT_LOADED = int64(-1) +// DynamicGroupAllowedFields are the recipient fields a dynamic group may filter on +var DynamicGroupAllowedFields = []string{ + "position", + "department", + "city", + "country", + "misc", +} + // RecipientGroup is an entity for recipient group type RecipientGroup struct { ID nullable.Nullable[uuid.UUID] `json:"id"` @@ -20,6 +30,11 @@ type RecipientGroup struct { Name nullable.Nullable[vo.String127] `json:"name"` CompanyID nullable.Nullable[uuid.UUID] `json:"companyID"` + // IsDynamic indicates members are resolved at query time via FilterField/FilterValue + IsDynamic nullable.Nullable[bool] `json:"isDynamic"` + FilterField nullable.Nullable[string] `json:"filterField"` + FilterValue nullable.Nullable[string] `json:"filterValue"` + Recipients []*Recipient `json:"-"` IsRecipientsLoaded bool `json:"-"` RecipientCount nullable.Nullable[int64] `json:"recipientCount"` @@ -35,6 +50,31 @@ func (rg *RecipientGroup) Validate() error { return nil } +// ValidateDynamic checks that a dynamic group has valid filter fields set, +// that filterField is in the allowed list, and that filterValue is a +// non-empty, non-whitespace string of at most 255 characters. +func (rg *RecipientGroup) ValidateDynamic() error { + if err := validate.NullableFieldRequired("filterField", rg.FilterField); err != nil { + return err + } + ff := rg.FilterField.MustGet() + if err := validate.ErrorIfNotContains(DynamicGroupAllowedFields, ff); err != nil { + return validate.WrapErrorWithField(err, "filterField") + } + + if err := validate.NullableFieldRequired("filterValue", rg.FilterValue); err != nil { + return err + } + fv := strings.TrimSpace(rg.FilterValue.MustGet()) + if err := validate.ErrorIfStringEmpty(fv); err != nil { + return validate.WrapErrorWithField(err, "filterValue") + } + if err := validate.ErrorIfStringGreaterThan(fv, 255); err != nil { + return validate.WrapErrorWithField(err, "filterValue") + } + return nil +} + // ToDBMap converts the fields that can be stored or updated to a map // if the value is nullable and not set, it is not included // if the value is nullable and set, it is included, if it is null, it is set to nil @@ -51,5 +91,14 @@ func (rg *RecipientGroup) ToDBMap() map[string]any { m["company_id"] = rg.CompanyID.MustGet() } } + if rg.IsDynamic.IsSpecified() { + m["is_dynamic"] = rg.IsDynamic.MustGet() + } + if rg.FilterField.IsSpecified() { + m["filter_field"] = rg.FilterField.MustGet() + } + if rg.FilterValue.IsSpecified() { + m["filter_value"] = rg.FilterValue.MustGet() + } return m } diff --git a/backend/repository/recipientGroup.go b/backend/repository/recipientGroup.go index 39d024d..72cd7a1 100644 --- a/backend/repository/recipientGroup.go +++ b/backend/repository/recipientGroup.go @@ -3,6 +3,7 @@ package repository import ( "context" "fmt" + "slices" "github.com/google/uuid" "github.com/oapi-codegen/nullable" @@ -149,7 +150,8 @@ func (rg *RecipientGroup) AddRecipients( return nil } -// countRecipients gets the count of recipient groups +// countRecipients gets the count of recipients in a group. +// uses the already-loaded group to avoid an extra DB fetch. func (rg *RecipientGroup) countRecipients( ctx context.Context, group *database.RecipientGroup, @@ -157,12 +159,19 @@ func (rg *RecipientGroup) countRecipients( ) (int64, error) { count := model.RECIPIENT_COUNT_NOT_LOADED if options.WithRecipientCount { - // if recipients is loaded then we can get the count from the slice - if options.WithRecipients { + // dynamic groups always require a filter query; the junction table is not used + if group.IsDynamic { + c, err := rg.countDynamicRecipients(ctx, group) + if err != nil { + return count, errs.Wrap(err) + } + count = c + } else if options.WithRecipients { + // if recipients is loaded then we can get the count from the slice count = int64(len(group.Recipients)) } else { - // otherwise we need to query the storage - c, err := rg.GetRecipientCount(ctx, group.ID) + // otherwise we need to query the junction table + c, err := rg.countStaticRecipients(ctx, group.ID) if err != nil { return count, errs.Wrap(err) } @@ -276,10 +285,32 @@ func (rg *RecipientGroup) GetAllByCompanyID( return recipientGroups, nil } -// GetRecipientCount gets the recipient count of a recipient group +// GetRecipientCount gets the recipient count of a recipient group. +// for dynamic groups the count is derived from the filter query; for static groups +// it counts rows in the junction table. func (rg *RecipientGroup) GetRecipientCount( ctx context.Context, groupID *uuid.UUID, +) (int64, error) { + // fetch the group to determine whether it is dynamic + var group database.RecipientGroup + res := rg.DB. + Where(TableColumnID(database.RECIPIENT_GROUP_TABLE)+" = ?", groupID.String()). + First(&group) + if res.Error != nil { + return 0, res.Error + } + + if group.IsDynamic { + return rg.countDynamicRecipients(ctx, &group) + } + return rg.countStaticRecipients(ctx, groupID) +} + +// countStaticRecipients counts rows in the junction table for a static group +func (rg *RecipientGroup) countStaticRecipients( + ctx context.Context, + groupID *uuid.UUID, ) (int64, error) { var count int64 result := rg.DB. @@ -298,6 +329,25 @@ func (rg *RecipientGroup) GetRecipientCount( return count, nil } +// countDynamicRecipients counts recipients that match a dynamic group's filter +func (rg *RecipientGroup) countDynamicRecipients( + ctx context.Context, + group *database.RecipientGroup, +) (int64, error) { + if !slices.Contains(model.DynamicGroupAllowedFields, group.FilterField) { + return 0, fmt.Errorf("invalid filter field: %s", group.FilterField) + } + db := rg.DB.Model(&database.Recipient{}). + Where(fmt.Sprintf("`%s`.`deleted_at` IS NULL", database.RECIPIENT_TABLE)). + Where(fmt.Sprintf("`%s`.`%s` = ?", database.RECIPIENT_TABLE, group.FilterField), group.FilterValue) + db = whereCompany(db, database.RECIPIENT_TABLE, group.CompanyID) + var count int64 + if err := db.Count(&count).Error; err != nil { + return 0, err + } + return count, nil +} + // GetByID gets a recipient group by id func (rg *RecipientGroup) GetByID( ctx context.Context, @@ -393,13 +443,29 @@ func (rg *RecipientGroup) GetByNameAndCompanyID( return recpGroup, nil } -// GetRecipientsByGroupID gets recipients by recipient group id +// GetRecipientsByGroupID gets recipients by recipient group id. +// for dynamic groups members are resolved by the filter query; for static groups +// members are fetched from the junction table. func (rg *RecipientGroup) GetRecipientsByGroupID( ctx context.Context, id *uuid.UUID, options *RecipientOption, ) (*model.Result[model.Recipient], error) { result := model.NewEmptyResult[model.Recipient]() + + // fetch the group to determine whether it is dynamic + var group database.RecipientGroup + res := rg.DB. + Where(TableColumnID(database.RECIPIENT_GROUP_TABLE)+" = ?", id.String()). + First(&group) + if res.Error != nil { + return result, res.Error + } + + if group.IsDynamic { + return rg.getDynamicRecipientsByGroup(ctx, &group, options) + } + db := rg.DB var recipients []database.Recipient if options.WithCompany { @@ -443,6 +509,55 @@ func (rg *RecipientGroup) GetRecipientsByGroupID( return result, nil } +// getDynamicRecipientsByGroup resolves members of a dynamic group via its filter +func (rg *RecipientGroup) getDynamicRecipientsByGroup( + ctx context.Context, + group *database.RecipientGroup, + options *RecipientOption, +) (*model.Result[model.Recipient], error) { + result := model.NewEmptyResult[model.Recipient]() + + if !slices.Contains(model.DynamicGroupAllowedFields, group.FilterField) { + return result, fmt.Errorf("invalid filter field: %s", group.FilterField) + } + + db := rg.DB + if options.WithCompany { + db = db.Preload("Company") + } + db, err := useQuery(db, database.RECIPIENT_TABLE, options.QueryArgs, allowdRecipientColumns...) + if err != nil { + return result, errs.Wrap(err) + } + db = db.Model(&database.Recipient{}). + Where(fmt.Sprintf("`%s`.`deleted_at` IS NULL", database.RECIPIENT_TABLE)). + Where(fmt.Sprintf("`%s`.`%s` = ?", database.RECIPIENT_TABLE, group.FilterField), group.FilterValue) + db = whereCompany(db, database.RECIPIENT_TABLE, group.CompanyID) + + var recipients []database.Recipient + dbRes := db.Find(&recipients) + if dbRes.Error != nil { + return result, dbRes.Error + } + + hasNextPage, err := useHasNextPage( + db, database.RECIPIENT_TABLE, options.QueryArgs, allowdRecipientColumns..., + ) + if err != nil { + return result, errs.Wrap(err) + } + result.HasNextPage = hasNextPage + + for _, recipient := range recipients { + r, err := ToRecipient(&recipient) + if err != nil { + return nil, errs.Wrap(err) + } + result.Rows = append(result.Rows, r) + } + return result, nil +} + // UpdateByID updates a recipient group by id func (rg *RecipientGroup) UpdateByID( ctx context.Context, @@ -529,6 +644,7 @@ func (rg *RecipientGroup) DeleteByID( return nil } +// ToRecipientGroup converts a database row to a model func ToRecipientGroup(row *database.RecipientGroup) (*model.RecipientGroup, error) { id := nullable.NewNullableWithValue(*row.ID) companyID := nullable.NewNullNullable[uuid.UUID]() @@ -553,6 +669,9 @@ func ToRecipientGroup(row *database.RecipientGroup) (*model.RecipientGroup, erro UpdatedAt: row.UpdatedAt, Name: name, CompanyID: companyID, + IsDynamic: nullable.NewNullableWithValue(row.IsDynamic), + FilterField: nullable.NewNullableWithValue(row.FilterField), + FilterValue: nullable.NewNullableWithValue(row.FilterValue), Recipients: recipients, RecipientCount: nullable.NewNullNullable[int64](), }, nil diff --git a/backend/service/campaign.go b/backend/service/campaign.go index ba4cbc0..6a71b05 100644 --- a/backend/service/campaign.go +++ b/backend/service/campaign.go @@ -290,19 +290,47 @@ func (c *Campaign) schedule( group, err := c.RecipientGroupRepository.GetByID( ctx, groupID, - &repository.RecipientGroupOption{ - WithRecipients: true, - }, + &repository.RecipientGroupOption{}, ) if err != nil { c.Logger.Errorw("failed to get recipient group by id", "error", err) return errs.Wrap(err) } - recps := group.Recipients - if recps == nil { - c.Logger.Error("recipient group did not load recipients") - return errors.New("recipient group did not load recipients") + + var recps []*model.Recipient + // dynamic groups resolve members via their filter query at schedule time + isDynamic := group.IsDynamic.IsSpecified() && !group.IsDynamic.IsNull() && group.IsDynamic.MustGet() + if isDynamic { + result, err := c.RecipientGroupRepository.GetRecipientsByGroupID( + ctx, + groupID, + &repository.RecipientOption{}, + ) + if err != nil { + c.Logger.Errorw("failed to get dynamic group recipients", "error", err) + return errs.Wrap(err) + } + recps = result.Rows + } else { + // static groups: reload with recipients preloaded from the junction table + groupWithRecipients, err := c.RecipientGroupRepository.GetByID( + ctx, + groupID, + &repository.RecipientGroupOption{ + WithRecipients: true, + }, + ) + if err != nil { + c.Logger.Errorw("failed to get recipient group recipients", "error", err) + return errs.Wrap(err) + } + recps = groupWithRecipients.Recipients + if recps == nil { + c.Logger.Error("recipient group did not load recipients") + return errors.New("recipient group did not load recipients") + } } + // collect all and remove duplicates for _, recp := range recps { id := recp.ID.MustGet().String() diff --git a/backend/service/recipientGroup.go b/backend/service/recipientGroup.go index ba126b7..c1f426a 100644 --- a/backend/service/recipientGroup.go +++ b/backend/service/recipientGroup.go @@ -42,6 +42,13 @@ func (r *RecipientGroup) Create( r.AuditLogNotAuthorized(ae) return nil, errs.ErrAuthorizationFailed } + // if dynamic, validate that filter fields are present, allowed, and non-empty + isDynamic := group.IsDynamic.IsSpecified() && !group.IsDynamic.IsNull() && group.IsDynamic.MustGet() + if isDynamic { + if err := group.ValidateDynamic(); err != nil { + return nil, errs.Wrap(err) + } + } // check uniqueness var companyID *uuid.UUID if cid, err := group.CompanyID.Get(); err == nil { @@ -112,7 +119,7 @@ func (r *RecipientGroup) Import( return nil, validate.WrapErrorWithField(errors.New("no recipients"), "add recipients") } // check that the recipient group exists - _, err = r.RecipientGroupRepository.GetByID( + group, err := r.RecipientGroupRepository.GetByID( ctx, recipientGroupID, &repository.RecipientGroupOption{}, @@ -121,6 +128,10 @@ func (r *RecipientGroup) Import( r.Logger.Debugw("failed to import recipients - failed to get recipient group", "error", err) return nil, err } + // dynamic groups do not support explicit membership via import + if group.IsDynamic.IsSpecified() && !group.IsDynamic.IsNull() && group.IsDynamic.MustGet() { + return nil, validate.WrapErrorWithField(errors.New("cannot import recipients into a dynamic group"), "group") + } result, err := r.RecipientService.Import( ctx, session, @@ -346,6 +357,34 @@ func (r *RecipientGroup) UpdateByID( } current.Name.Set(name) } + // disallow toggling isDynamic after creation + if incoming.IsDynamic.IsSpecified() && !incoming.IsDynamic.IsNull() { + currentIsDynamic := current.IsDynamic.IsSpecified() && !current.IsDynamic.IsNull() && current.IsDynamic.MustGet() + if incoming.IsDynamic.MustGet() != currentIsDynamic { + return validate.WrapErrorWithField(errors.New("cannot change after creation"), "isDynamic") + } + } + + isDynamic := current.IsDynamic.IsSpecified() && !current.IsDynamic.IsNull() && current.IsDynamic.MustGet() + if isDynamic { + // propagate filter field/value updates; ValidateDynamic carries the allowlist check + if incoming.FilterField.IsSpecified() && !incoming.FilterField.IsNull() { + current.FilterField.Set(incoming.FilterField.MustGet()) + } + if incoming.FilterValue.IsSpecified() && !incoming.FilterValue.IsNull() { + current.FilterValue.Set(incoming.FilterValue.MustGet()) + } + // re-validate the merged state so allowlist and length rules are enforced + if err := current.ValidateDynamic(); err != nil { + return errs.Wrap(err) + } + } else { + // reject filter field/value changes on a static group + if (incoming.FilterField.IsSpecified() && !incoming.FilterField.IsNull()) || + (incoming.FilterValue.IsSpecified() && !incoming.FilterValue.IsNull()) { + return validate.WrapErrorWithField(errors.New("cannot set filter fields on a static group"), "filterField") + } + } // update recipient group err = r.RecipientGroupRepository.UpdateByID( ctx, @@ -363,7 +402,8 @@ func (r *RecipientGroup) UpdateByID( return nil } -// AddRecipients adds recipients to a recipient group +// AddRecipients adds recipients to a recipient group. +// returns an error if the group is dynamic, as membership is derived from the filter query. func (r *RecipientGroup) AddRecipients( ctx context.Context, session *model.Session, @@ -393,6 +433,10 @@ func (r *RecipientGroup) AddRecipients( r.Logger.Errorw("failed to add recipients - failed to get recipient group", "error", err) return err } + // dynamic groups do not support explicit membership + if group.IsDynamic.IsSpecified() && !group.IsDynamic.IsNull() && group.IsDynamic.MustGet() { + return validate.WrapErrorWithField(errors.New("cannot add recipients to a dynamic group"), "group") + } // check if the recipients can be added to group for _, recipientID := range recipients { recipient, err := r.RecipientRepository.GetByID( @@ -440,7 +484,8 @@ func (r *RecipientGroup) AddRecipients( return nil } -// RemoveRecipients removes a recipient from a recipient group +// RemoveRecipients removes recipients from a recipient group. +// returns an error if the group is dynamic, as membership is derived from the filter query. func (r *RecipientGroup) RemoveRecipients( ctx context.Context, session *model.Session, @@ -460,6 +505,19 @@ func (r *RecipientGroup) RemoveRecipients( r.AuditLogNotAuthorized(ae) return errs.ErrAuthorizationFailed } + // dynamic groups do not support explicit membership removal + existingGroup, err := r.RecipientGroupRepository.GetByID( + ctx, + groupID, + &repository.RecipientGroupOption{}, + ) + if err != nil { + r.Logger.Errorw("failed to remove recipients - failed to get recipient group", "error", err) + return err + } + if existingGroup.IsDynamic.IsSpecified() && !existingGroup.IsDynamic.IsNull() && existingGroup.IsDynamic.MustGet() { + return validate.WrapErrorWithField(errors.New("cannot remove recipients from a dynamic group"), "group") + } // anonymize recipients in any recipient-campaign data for _, recpID := range recipientIDs { // if the recipient is in any active campaign, cancel the recipient sending @@ -527,7 +585,8 @@ func (r *RecipientGroup) DeleteByID( r.AuditLogNotAuthorized(ae) return errs.ErrAuthorizationFailed } - // get all recipients in group + // get all recipients in group; for dynamic groups the junction table is empty so we + // must resolve members via the filter query before anonymizing campaign data group, err := r.RecipientGroupRepository.GetByID( ctx, id, @@ -535,9 +594,30 @@ func (r *RecipientGroup) DeleteByID( WithRecipients: true, }, ) - if len(group.Recipients) > 0 { + if err != nil { + r.Logger.Errorw("failed to delete recipient group - failed to get recipient group", "error", err) + return err + } + + recipients := group.Recipients + isDynamic := group.IsDynamic.IsSpecified() && !group.IsDynamic.IsNull() && group.IsDynamic.MustGet() + if isDynamic { + // dynamic groups carry no junction-table rows; resolve members via filter query + dynamicResult, err := r.RecipientGroupRepository.GetRecipientsByGroupID( + ctx, + id, + &repository.RecipientOption{}, + ) + if err != nil { + r.Logger.Errorw("failed to delete recipient group - failed to get dynamic group recipients", "error", err) + return err + } + recipients = dynamicResult.Rows + } + + if len(recipients) > 0 { // anonymize recipients in any recipient-campaign data - for _, recipient := range group.Recipients { + for _, recipient := range recipients { anonymizedID := uuid.New() recpID := recipient.ID.MustGet() diff --git a/frontend/src/lib/api/api.js b/frontend/src/lib/api/api.js index 2731535..bf140f1 100644 --- a/frontend/src/lib/api/api.js +++ b/frontend/src/lib/api/api.js @@ -2516,6 +2516,25 @@ export class API { }); }, + /** + * Create a dynamic recipient group. + * + * @param {string} name + * @param {string|null} companyID + * @param {string} filterField - recipient attribute to filter on (position, department, city, country, misc) + * @param {string} filterValue - value to match against the filter field + * @returns {Promise} + */ + createDynamicGroup: async (name, companyID, filterField, filterValue) => { + return await postJSON(this.getPath('/recipient/group'), { + name: name, + companyID: companyID, + isDynamic: true, + filterField: filterField, + filterValue: filterValue + }); + }, + /** * Update a recipient group - not the recipients in the group. * @@ -2532,6 +2551,25 @@ export class API { }); }, + /** + * Update a dynamic recipient group. + * + * @param {object} group + * @param {string} group.id + * @param {string} [group.name] + * @param {string} [group.filterField] + * @param {string} [group.filterValue] + * @returns {Promise} + */ + updateDynamicGroup: async ({ id, name, filterField, filterValue }) => { + return await patchJSON(this.getPath(`/recipient/group/${id}`), { + name: name, + isDynamic: true, + filterField: filterField, + filterValue: filterValue + }); + }, + /** * Get all recipient groups using pagination. * diff --git a/frontend/src/lib/components/DynamicLabel.svelte b/frontend/src/lib/components/DynamicLabel.svelte new file mode 100644 index 0000000..b2ee93f --- /dev/null +++ b/frontend/src/lib/components/DynamicLabel.svelte @@ -0,0 +1,6 @@ + + dynamic + diff --git a/frontend/src/routes/recipient/group/+page.svelte b/frontend/src/routes/recipient/group/+page.svelte index a968d4e..1aca3eb 100644 --- a/frontend/src/routes/recipient/group/+page.svelte +++ b/frontend/src/routes/recipient/group/+page.svelte @@ -6,6 +6,8 @@ import { goto } from '$app/navigation'; import Headline from '$lib/components/Headline.svelte'; import TextField from '$lib/components/TextField.svelte'; + import DynamicLabel from '$lib/components/DynamicLabel.svelte'; + import TextFieldSelect from '$lib/components/TextFieldSelect.svelte'; import TableRow from '$lib/components/table/TableRow.svelte'; import TableCell from '$lib/components/table/TableCell.svelte'; import TableCellLink from '$lib/components/table/TableCellLink.svelte'; @@ -33,15 +35,29 @@ // services const appStateService = AppStateService.instance; + // the recipient fields that can be used as a dynamic filter + const dynamicFilterFields = ['position', 'department', 'city', 'country', 'misc']; + // data let form = null; + let dynamicForm = null; let modalError = ''; + let dynamicModalError = ''; + let formValues = { name: null, companyID: null, recipients: [] }; + + let dynamicFormValues = { + name: null, + filterField: dynamicFilterFields[0], + filterValue: null + }; + let isModalVisible = false; + let isDynamicModalVisible = false; let isSubmitting = false; let isTableLoading = false; const tableURLParams = newTableURLParams(); @@ -57,6 +73,17 @@ name: null }; + // dynamic update modal state + let isDynamicUpdateModalVisible = false; + let dynamicUpdateForm = null; + let dynamicUpdateError = ''; + let dynamicUpdateValues = { + id: null, + name: null, + filterField: dynamicFilterFields[0], + filterValue: null + }; + $: { modalText = modalMode === 'create' ? 'New group' : 'Update group'; } @@ -162,6 +189,54 @@ } }; + const onSubmitDynamicCreate = async () => { + try { + isSubmitting = true; + const res = await api.recipient.createDynamicGroup( + dynamicFormValues.name, + contextCompanyID, + dynamicFormValues.filterField, + dynamicFormValues.filterValue + ); + if (!res.success) { + dynamicModalError = res.error; + return; + } + addToast('Dynamic group created', 'Success'); + refreshGroups(); + closeDynamicModal(); + } catch (e) { + addToast('Failed to create dynamic group', 'Error'); + console.error('failed to create dynamic group', e); + } finally { + isSubmitting = false; + } + }; + + const onSubmitDynamicUpdate = async () => { + try { + isSubmitting = true; + const res = await api.recipient.updateDynamicGroup({ + id: dynamicUpdateValues.id, + name: dynamicUpdateValues.name, + filterField: dynamicUpdateValues.filterField, + filterValue: dynamicUpdateValues.filterValue + }); + if (!res.success) { + dynamicUpdateError = res.error; + return; + } + addToast('Dynamic group updated', 'Success'); + refreshGroups(); + closeDynamicUpdateModal(); + } catch (err) { + addToast('Failed to update dynamic group', 'Error'); + console.error('failed to update dynamic group', err); + } finally { + isSubmitting = false; + } + }; + const openDeleteAlert = async (group) => { isDeleteAlertVisible = true; deleteValues.id = group.id; @@ -195,6 +270,16 @@ isModalVisible = true; }; + const openDynamicCreateModal = () => { + dynamicFormValues = { + name: null, + filterField: dynamicFilterFields[0], + filterValue: null + }; + dynamicModalError = ''; + isDynamicModalVisible = true; + }; + /** @param {string} id */ const openUpdateModal = async (id) => { modalMode = 'update'; @@ -212,11 +297,42 @@ } }; + /** @param {string} id */ + const openDynamicUpdateModal = async (id) => { + try { + showIsLoading(); + const group = await loadGroup(id); + dynamicUpdateValues.id = id; + dynamicUpdateValues.name = group.name; + dynamicUpdateValues.filterField = group.filterField ?? dynamicFilterFields[0]; + dynamicUpdateValues.filterValue = group.filterValue ?? ''; + dynamicUpdateError = ''; + isDynamicUpdateModalVisible = true; + } catch (e) { + addToast('Failed to load group', 'Error'); + console.error('failed to load group', e); + } finally { + hideIsLoading(); + } + }; + const closeModal = () => { isModalVisible = false; form.reset(); modalError = ''; }; + + const closeDynamicModal = () => { + isDynamicModalVisible = false; + if (dynamicForm) dynamicForm.reset(); + dynamicModalError = ''; + }; + + const closeDynamicUpdateModal = () => { + isDynamicUpdateModalVisible = false; + if (dynamicUpdateForm) dynamicUpdateForm.reset(); + dynamicUpdateError = ''; + }; @@ -224,6 +340,7 @@ Groups
New group + New dynamic group goto('/recipient/orphaned/')}>View Orphaned
+ {#if group.isDynamic} + + {/if} {group.name} {#if group.scimEnabled} {group.recipientCount} + {#if contextCompanyID} {/if} @@ -266,10 +387,17 @@ on:click={() => gotoEditGroupRecipients(group.id)} {...globalButtonDisabledAttributes(group, contextCompanyID)} /> - openUpdateModal(group.id)} - {...globalButtonDisabledAttributes(group, contextCompanyID)} - /> + {#if group.isDynamic} + openDynamicUpdateModal(group.id)} + {...globalButtonDisabledAttributes(group, contextCompanyID)} + /> + {:else} + openUpdateModal(group.id)} + {...globalButtonDisabledAttributes(group, contextCompanyID)} + /> + {/if} openDeleteAlert(group)} {...globalButtonDisabledAttributes(group, contextCompanyID)} @@ -280,6 +408,7 @@ {/each}
+ @@ -291,21 +420,91 @@ bind:value={formValues.name} placeholder="Marketing">Name -
- {#each formValues.recipients as recipient} -
-
- {recipient.email} -
-
- {/each} -
+ + + + + + + Name + + Filter field + + Filter value + + + + + + + + + + + + + Name + + Filter field + + Filter value + + + + + + + import { onMount } from 'svelte'; + import DynamicLabel from '$lib/components/DynamicLabel.svelte'; import { api } from '$lib/api/apiProxy.js'; import { newTableURLParams } from '$lib/service/tableURLParams.js'; import { page } from '$app/stores'; @@ -45,7 +46,10 @@ let groupValues = { id: null, name: null, - companyID: null + companyID: null, + isDynamic: false, + filterField: null, + filterValue: null }; let recipientSearch = ''; let searchOptions = []; @@ -112,6 +116,9 @@ groupValues.id = res.data.id; groupValues.name = res.data.name; groupValues.companyID = res.data.companyID; + groupValues.isDynamic = res.data.isDynamic ?? false; + groupValues.filterField = res.data.filterField ?? null; + groupValues.filterValue = res.data.filterValue ?? null; } catch (err) { addToast('Failed to load group', 'Error'); console.error('failed to load group', err); @@ -395,16 +402,35 @@
Group Recipients - {groupValues.name} - Add Recipients - Import from CSV - Recipients + + {#if groupValues.isDynamic} + + {/if} + {groupValues.name} + + {#if groupValues.isDynamic} +

Dynamic group

+

+ Recipients where + {groupValues.filterField} + equals + {groupValues.filterValue} + are included automatically. +

+ {:else} + Add Recipients + Import from CSV + {/if} - openDeleteAlert(recipient)} - {...globalButtonDisabledAttributes(recipient, contextCompanyID)} - > + {#if !groupValues.isDynamic} + openDeleteAlert(recipient)} + {...globalButtonDisabledAttributes(groupValues, contextCompanyID)} + > + {/if}