Merge branch 'develop' into feat-scim

This commit is contained in:
Ronni Skansing
2026-03-28 15:17:22 +01:00
10 changed files with 605 additions and 50 deletions
+1
View File
@@ -122,6 +122,7 @@ func NewServices(
CampaignRepository: repositories.Campaign,
CampaignRecipientRepository: repositories.CampaignRecipient,
}
recipientGroup := &service.RecipientGroup{
Common: common,
CampaignRepository: repositories.Campaign,
+9 -2
View File
@@ -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;"`
}
+49
View File
@@ -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
}
+126 -7
View File
@@ -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
+35 -7
View File
@@ -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()
+86 -6
View File
@@ -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()
+38
View File
@@ -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>
+212 -13
View File
@@ -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>