mirror of
https://github.com/phishingclub/phishingclub.git
synced 2026-07-14 07:47:24 +02:00
Merge branch 'develop' into feat-scim
This commit is contained in:
@@ -122,6 +122,7 @@ func NewServices(
|
||||
CampaignRepository: repositories.Campaign,
|
||||
CampaignRecipientRepository: repositories.CampaignRecipient,
|
||||
}
|
||||
|
||||
recipientGroup := &service.RecipientGroup{
|
||||
Common: common,
|
||||
CampaignRepository: repositories.Campaign,
|
||||
|
||||
@@ -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;"`
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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<ApiResponse>}
|
||||
*/
|
||||
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<ApiResponse>}
|
||||
*/
|
||||
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.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
<span
|
||||
title="Dynamic group"
|
||||
class="select-none px-1 border-2 border-gray-400 dark:border-gray-500 relative -top-1 rounded-lg text-xs text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 transition-colors duration-200"
|
||||
>
|
||||
dynamic
|
||||
</span>
|
||||
@@ -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 = '';
|
||||
};
|
||||
</script>
|
||||
|
||||
<HeadTitle title="Groups" />
|
||||
@@ -224,6 +340,7 @@
|
||||
<Headline>Groups</Headline>
|
||||
<div class="flex gap-3">
|
||||
<BigButton on:click={openCreateModal}>New group</BigButton>
|
||||
<BigButton on:click={openDynamicCreateModal}>New dynamic group</BigButton>
|
||||
<BigButton on:click={() => goto('/recipient/orphaned/')}>View Orphaned</BigButton>
|
||||
</div>
|
||||
<Table
|
||||
@@ -242,6 +359,9 @@
|
||||
{#each groups as group}
|
||||
<TableRow>
|
||||
<TableCellLink href={`/recipient/group/${group.id}`} title={group.name}>
|
||||
{#if group.isDynamic}
|
||||
<DynamicLabel />
|
||||
{/if}
|
||||
{group.name}
|
||||
{#if group.scimEnabled}
|
||||
<span
|
||||
@@ -255,6 +375,7 @@
|
||||
<TableCellLink href={`/recipient/group/${group.id}`} title={group.recipientCount}>
|
||||
{group.recipientCount}
|
||||
</TableCellLink>
|
||||
|
||||
{#if contextCompanyID}
|
||||
<TableCellScope companyID={group.companyID} />
|
||||
{/if}
|
||||
@@ -266,10 +387,17 @@
|
||||
on:click={() => gotoEditGroupRecipients(group.id)}
|
||||
{...globalButtonDisabledAttributes(group, contextCompanyID)}
|
||||
/>
|
||||
<TableUpdateButton
|
||||
on:click={() => openUpdateModal(group.id)}
|
||||
{...globalButtonDisabledAttributes(group, contextCompanyID)}
|
||||
/>
|
||||
{#if group.isDynamic}
|
||||
<TableUpdateButton
|
||||
on:click={() => openDynamicUpdateModal(group.id)}
|
||||
{...globalButtonDisabledAttributes(group, contextCompanyID)}
|
||||
/>
|
||||
{:else}
|
||||
<TableUpdateButton
|
||||
on:click={() => openUpdateModal(group.id)}
|
||||
{...globalButtonDisabledAttributes(group, contextCompanyID)}
|
||||
/>
|
||||
{/if}
|
||||
<TableDeleteButton
|
||||
on:click={() => openDeleteAlert(group)}
|
||||
{...globalButtonDisabledAttributes(group, contextCompanyID)}
|
||||
@@ -280,6 +408,7 @@
|
||||
{/each}
|
||||
</Table>
|
||||
|
||||
<!-- static group modal -->
|
||||
<Modal headerText={modalText} bind:visible={isModalVisible} onClose={closeModal} {isSubmitting}>
|
||||
<FormGrid on:submit={onSubmit} bind:bindTo={form} {isSubmitting}>
|
||||
<FormColumns>
|
||||
@@ -291,21 +420,91 @@
|
||||
bind:value={formValues.name}
|
||||
placeholder="Marketing">Name</TextField
|
||||
>
|
||||
<section>
|
||||
{#each formValues.recipients as recipient}
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
{recipient.email}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</section>
|
||||
</FormColumn>
|
||||
</FormColumns>
|
||||
<FormError message={modalError} />
|
||||
<FormFooter {closeModal} {isSubmitting} />
|
||||
</FormGrid>
|
||||
</Modal>
|
||||
|
||||
<!-- dynamic group create modal -->
|
||||
<Modal
|
||||
headerText="New dynamic group"
|
||||
bind:visible={isDynamicModalVisible}
|
||||
onClose={closeDynamicModal}
|
||||
{isSubmitting}
|
||||
>
|
||||
<FormGrid on:submit={onSubmitDynamicCreate} bind:bindTo={dynamicForm} {isSubmitting}>
|
||||
<FormColumns>
|
||||
<FormColumn>
|
||||
<TextField
|
||||
required
|
||||
minLength={1}
|
||||
maxLength={127}
|
||||
bind:value={dynamicFormValues.name}
|
||||
placeholder="Marketing">Name</TextField
|
||||
>
|
||||
<TextFieldSelect
|
||||
id="dynamic-group-filter-field"
|
||||
required
|
||||
bind:value={dynamicFormValues.filterField}
|
||||
options={dynamicFilterFields}
|
||||
>
|
||||
Filter field
|
||||
</TextFieldSelect>
|
||||
<TextField
|
||||
required
|
||||
minLength={1}
|
||||
maxLength={127}
|
||||
bind:value={dynamicFormValues.filterValue}
|
||||
placeholder="e.g. Engineering">Filter value</TextField
|
||||
>
|
||||
</FormColumn>
|
||||
</FormColumns>
|
||||
<FormError message={dynamicModalError} />
|
||||
<FormFooter closeModal={closeDynamicModal} {isSubmitting} />
|
||||
</FormGrid>
|
||||
</Modal>
|
||||
|
||||
<!-- dynamic group update modal -->
|
||||
<Modal
|
||||
headerText="Update dynamic group"
|
||||
bind:visible={isDynamicUpdateModalVisible}
|
||||
onClose={closeDynamicUpdateModal}
|
||||
{isSubmitting}
|
||||
>
|
||||
<FormGrid on:submit={onSubmitDynamicUpdate} bind:bindTo={dynamicUpdateForm} {isSubmitting}>
|
||||
<FormColumns>
|
||||
<FormColumn>
|
||||
<TextField
|
||||
required
|
||||
minLength={1}
|
||||
maxLength={127}
|
||||
bind:value={dynamicUpdateValues.name}
|
||||
placeholder="Marketing">Name</TextField
|
||||
>
|
||||
<TextFieldSelect
|
||||
id="dynamic-group-filter-field-a"
|
||||
required
|
||||
bind:value={dynamicUpdateValues.filterField}
|
||||
options={dynamicFilterFields}
|
||||
>
|
||||
Filter field
|
||||
</TextFieldSelect>
|
||||
<TextField
|
||||
required
|
||||
minLength={1}
|
||||
maxLength={127}
|
||||
bind:value={dynamicUpdateValues.filterValue}
|
||||
placeholder="e.g. Engineering">Filter value</TextField
|
||||
>
|
||||
</FormColumn>
|
||||
</FormColumns>
|
||||
<FormError message={dynamicUpdateError} />
|
||||
<FormFooter closeModal={closeDynamicUpdateModal} {isSubmitting} />
|
||||
</FormGrid>
|
||||
</Modal>
|
||||
|
||||
<DeleteAlert
|
||||
list={[
|
||||
'All assets will be deleted',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script>
|
||||
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 @@
|
||||
<HeadTitle title="Group ({groupValues.name}" />
|
||||
<main>
|
||||
<Headline>Group Recipients</Headline>
|
||||
<SubHeadline>{groupValues.name}</SubHeadline>
|
||||
<BigButton
|
||||
on:click={openAddRecipientsModal}
|
||||
{...globalButtonDisabledAttributes(groupValues, contextCompanyID)}>Add Recipients</BigButton
|
||||
>
|
||||
<BigButton
|
||||
on:click={openImportModal}
|
||||
{...globalButtonDisabledAttributes(groupValues, contextCompanyID)}>Import from CSV</BigButton
|
||||
>
|
||||
<SubHeadline>Recipients</SubHeadline>
|
||||
<SubHeadline>
|
||||
{#if groupValues.isDynamic}
|
||||
<DynamicLabel />
|
||||
{/if}
|
||||
{groupValues.name}
|
||||
</SubHeadline>
|
||||
{#if groupValues.isDynamic}
|
||||
<p class="text-sm font-semibold text-slate-600 dark:text-gray-400 mt-2">Dynamic group</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mb-8">
|
||||
Recipients where
|
||||
<span class="font-mono text-xs bg-gray-200 dark:bg-gray-700 px-1 rounded"
|
||||
>{groupValues.filterField}</span
|
||||
>
|
||||
equals
|
||||
<span class="font-mono text-xs bg-gray-200 dark:bg-gray-700 px-1 rounded"
|
||||
>{groupValues.filterValue}</span
|
||||
>
|
||||
are included automatically.
|
||||
</p>
|
||||
{:else}
|
||||
<BigButton
|
||||
on:click={openAddRecipientsModal}
|
||||
{...globalButtonDisabledAttributes(groupValues, contextCompanyID)}>Add Recipients</BigButton
|
||||
>
|
||||
<BigButton
|
||||
on:click={openImportModal}
|
||||
{...globalButtonDisabledAttributes(groupValues, contextCompanyID)}>Import from CSV</BigButton
|
||||
>
|
||||
{/if}
|
||||
<Table
|
||||
columns={[
|
||||
{ column: 'Email', size: 'large' },
|
||||
@@ -456,10 +482,12 @@
|
||||
<TableCellEmpty />
|
||||
<TableCellAction>
|
||||
<TableDropDownEllipsis>
|
||||
<TableDeleteButton
|
||||
on:click={() => openDeleteAlert(recipient)}
|
||||
{...globalButtonDisabledAttributes(recipient, contextCompanyID)}
|
||||
></TableDeleteButton>
|
||||
{#if !groupValues.isDynamic}
|
||||
<TableDeleteButton
|
||||
on:click={() => openDeleteAlert(recipient)}
|
||||
{...globalButtonDisabledAttributes(groupValues, contextCompanyID)}
|
||||
></TableDeleteButton>
|
||||
{/if}
|
||||
</TableDropDownEllipsis>
|
||||
</TableCellAction>
|
||||
</TableRow>
|
||||
|
||||
Reference in New Issue
Block a user