From 170f92aa7285d360b45b45f233f153ac6fb0f23f Mon Sep 17 00:00:00 2001 From: Ronni Skansing Date: Thu, 4 Dec 2025 11:23:52 +0100 Subject: [PATCH] added status modal after import recipients Signed-off-by: Ronni Skansing --- backend/controller/recipient.go | 4 +- backend/controller/recipientGroup.go | 4 +- backend/service/recipient.go | 146 ++++++++-- backend/service/recipientGroup.go | 20 +- frontend/src/routes/recipient/+page.svelte | 255 ++++++++++++++++- .../routes/recipient/group/[id]/+page.svelte | 256 +++++++++++++++++- 6 files changed, 647 insertions(+), 38 deletions(-) diff --git a/backend/controller/recipient.go b/backend/controller/recipient.go index 6029197..67d2add 100644 --- a/backend/controller/recipient.go +++ b/backend/controller/recipient.go @@ -490,7 +490,7 @@ func (r *Recipient) Import(g *gin.Context) { if !req.IgnoreOverwriteEmptyFields.IsSpecified() || req.IgnoreOverwriteEmptyFields.IsNull() { req.IgnoreOverwriteEmptyFields = nullable.NewNullableWithValue(true) } - _, err := r.RecipientService.Import( + result, err := r.RecipientService.Import( g, session, req.Recipients, @@ -500,7 +500,7 @@ func (r *Recipient) Import(g *gin.Context) { if ok := r.handleErrors(g, err); !ok { return } - r.Response.OK(g, &gin.H{}) + r.Response.OK(g, result) } // DeleteByID deletes a recipient by id diff --git a/backend/controller/recipientGroup.go b/backend/controller/recipientGroup.go index e007858..1b24212 100644 --- a/backend/controller/recipientGroup.go +++ b/backend/controller/recipientGroup.go @@ -221,7 +221,7 @@ func (r *RecipientGroup) Import(g *gin.Context) { req.IgnoreOverwriteEmptyFields = nullable.NewNullableWithValue(true) } - err := r.RecipientGroupService.Import( + result, err := r.RecipientGroupService.Import( g, session, req.Recipients, @@ -232,7 +232,7 @@ func (r *RecipientGroup) Import(g *gin.Context) { if ok := r.handleErrors(g, err); !ok { return } - r.Response.OK(g, &gin.H{}) + r.Response.OK(g, result) } // AddRecipients adds recipients to a recipient group diff --git a/backend/service/recipient.go b/backend/service/recipient.go index 9b07518..0db3b6e 100644 --- a/backend/service/recipient.go +++ b/backend/service/recipient.go @@ -5,6 +5,7 @@ import ( "fmt" "github.com/go-errors/errors" + "github.com/oapi-codegen/nullable" "github.com/google/uuid" "github.com/phishingclub/phishingclub/data" @@ -495,37 +496,84 @@ func (r *Recipient) GetByEmail( // Import imports recipients // if the recipient does not exists, it will be created and added to the group // if the recipient exits, it will be updated and added to the group + +// RecipientImportResult contains the results of importing recipients +type RecipientImportResult struct { + SuccessIDs []*uuid.UUID `json:"successIDs"` + CreatedRecipients []RecipientImportSuccess `json:"createdRecipients"` + UpdatedRecipients []RecipientImportSuccess `json:"updatedRecipients"` + Failures []RecipientImportFailure `json:"failures"` + Summary RecipientImportSummary `json:"summary"` +} + +// RecipientImportSuccess contains information about a successful import +type RecipientImportSuccess struct { + Email string `json:"email"` + FirstName string `json:"firstName"` + LastName string `json:"lastName"` + Index int `json:"index"` +} + +// RecipientImportFailure contains information about a failed import +type RecipientImportFailure struct { + Email string `json:"email"` + Index int `json:"index"` + Reason string `json:"reason"` +} + +// RecipientImportSummary contains summary statistics +type RecipientImportSummary struct { + Total int `json:"total"` + Success int `json:"success"` + Failed int `json:"failed"` + Created int `json:"created"` + Updated int `json:"updated"` +} + func (r *Recipient) Import( ctx context.Context, session *model.Session, recipients []*model.Recipient, ignoreOverwriteEmptyFields bool, companyID *uuid.UUID, -) ([]*uuid.UUID, error) { +) (*RecipientImportResult, error) { ae := NewAuditEvent("Recipient.Import", session) - recipientsIDs := []*uuid.UUID{} + result := &RecipientImportResult{ + SuccessIDs: []*uuid.UUID{}, + CreatedRecipients: []RecipientImportSuccess{}, + UpdatedRecipients: []RecipientImportSuccess{}, + Failures: []RecipientImportFailure{}, + Summary: RecipientImportSummary{ + Total: len(recipients), + }, + } // check permissions isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL) if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) { r.LogAuthError(err) - return recipientsIDs, errs.Wrap(err) + return result, errs.Wrap(err) } if !isAuthorized { r.AuditLogNotAuthorized(ae) - return recipientsIDs, errs.ErrAuthorizationFailed + return result, errs.ErrAuthorizationFailed } if len(recipients) == 0 { - return recipientsIDs, validate.WrapErrorWithField(errors.New("no recipients"), "add recipients") + return result, validate.WrapErrorWithField(errors.New("no recipients"), "add recipients") } - // first validate all the entries - for _, recipient := range recipients { - if err := recipient.Validate(); err != nil { - return recipientsIDs, errs.Wrap(err) + + // process each recipient individually, collecting successes and failures + for i, incoming := range recipients { + // validate the recipient + if err := incoming.Validate(); err != nil { + result.Failures = append(result.Failures, RecipientImportFailure{ + Email: getEmailFromRecipient(incoming), + Index: i, + Reason: err.Error(), + }) + result.Summary.Failed++ + continue } - } - // if the recipient does not exist, create it - // if the recipient exists, update it - for _, incoming := range recipients { + // check if the recipient exists email := incoming.Email.MustGet() current, err := r.RecipientRepository.GetByEmail( @@ -534,8 +582,17 @@ func (r *Recipient) Import( "id", "email", "company_id", ) if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { - r.Logger.Debugw("failed to import recipients - failed to get recipient", "error", err) - return recipientsIDs, errs.Wrap(err) + r.Logger.Debugw("failed to import recipient - failed to get recipient", + "error", err, + "email", email.String(), + ) + result.Failures = append(result.Failures, RecipientImportFailure{ + Email: email.String(), + Index: i, + Reason: "database error: " + err.Error(), + }) + result.Summary.Failed++ + continue } if current == nil { // create recipient @@ -548,12 +605,27 @@ func (r *Recipient) Import( incoming, ) if err != nil { - r.Logger.Debugw("failed to import recipients - failed to create recipient", + r.Logger.Debugw("failed to import recipient - failed to create recipient", "error", err, + "email", email.String(), ) - return recipientsIDs, errs.Wrap(err) + result.Failures = append(result.Failures, RecipientImportFailure{ + Email: email.String(), + Index: i, + Reason: "create failed: " + err.Error(), + }) + result.Summary.Failed++ + continue } - recipientsIDs = append(recipientsIDs, recipientID) + result.SuccessIDs = append(result.SuccessIDs, recipientID) + result.Summary.Success++ + result.Summary.Created++ + result.CreatedRecipients = append(result.CreatedRecipients, RecipientImportSuccess{ + Email: email.String(), + FirstName: getStringFromOptional(incoming.FirstName), + LastName: getStringFromOptional(incoming.LastName), + Index: i, + }) } else { // set the companyID to NOT SET, so it is not overwritten if supplied incoming.CompanyID.SetUnspecified() @@ -571,17 +643,47 @@ func (r *Recipient) Import( incoming, ) if err != nil { - r.Logger.Debugw("failed to import recipients - failed to update recipient", + r.Logger.Debugw("failed to import recipient - failed to update recipient", "error", err, + "email", email.String(), ) - return recipientsIDs, errs.Wrap(err) + result.Failures = append(result.Failures, RecipientImportFailure{ + Email: email.String(), + Index: i, + Reason: "update failed: " + err.Error(), + }) + result.Summary.Failed++ + continue } - recipientsIDs = append(recipientsIDs, &recipientID) + result.SuccessIDs = append(result.SuccessIDs, &recipientID) + result.Summary.Success++ + result.Summary.Updated++ + result.UpdatedRecipients = append(result.UpdatedRecipients, RecipientImportSuccess{ + Email: email.String(), + FirstName: getStringFromOptional(incoming.FirstName), + LastName: getStringFromOptional(incoming.LastName), + Index: i, + }) } } r.AuditLogAuthorized(ae) + return result, nil +} - return recipientsIDs, nil +// getEmailFromRecipient safely extracts email from recipient for error reporting +func getEmailFromRecipient(r *model.Recipient) string { + if r.Email.IsSpecified() && !r.Email.IsNull() { + return r.Email.MustGet().String() + } + return "" +} + +// getStringFromOptional safely extracts string from optional field +func getStringFromOptional(field nullable.Nullable[vo.OptionalString127]) string { + if field.IsSpecified() && !field.IsNull() { + return field.MustGet().String() + } + return "" } // Delete deletes a recipient diff --git a/backend/service/recipientGroup.go b/backend/service/recipientGroup.go index 35b9510..5b65264 100644 --- a/backend/service/recipientGroup.go +++ b/backend/service/recipientGroup.go @@ -92,7 +92,7 @@ func (r *RecipientGroup) Import( ignoreOverwriteEmptyFields bool, recipientGroupID *uuid.UUID, companyID *uuid.UUID, -) error { +) (*RecipientImportResult, error) { ae := NewAuditEvent("RecipientGroup.Import", session) ae.Details["recipientGroupId"] = recipientGroupID.String() if companyID != nil { @@ -102,14 +102,14 @@ func (r *RecipientGroup) Import( isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL) if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) { r.LogAuthError(err) - return err + return nil, err } if !isAuthorized { r.AuditLogNotAuthorized(ae) - return errs.ErrAuthorizationFailed + return nil, errs.ErrAuthorizationFailed } if len(recipients) == 0 { - return validate.WrapErrorWithField(errors.New("no recipients"), "add recipients") + return nil, validate.WrapErrorWithField(errors.New("no recipients"), "add recipients") } // check that the recipient group exists _, err = r.RecipientGroupRepository.GetByID( @@ -119,9 +119,9 @@ func (r *RecipientGroup) Import( ) if err != nil { r.Logger.Debugw("failed to import recipients - failed to get recipient group", "error", err) - return err + return nil, err } - recipientIDs, err := r.RecipientService.Import( + result, err := r.RecipientService.Import( ctx, session, recipients, @@ -129,24 +129,24 @@ func (r *RecipientGroup) Import( companyID, ) if err != nil { - return err + return result, err } // add recpients to group err = r.AddRecipients( ctx, session, recipientGroupID, - recipientIDs, + result.SuccessIDs, ) if err != nil { r.Logger.Debugw("failed to import recipients - failed to add recipients to group", "error", err, ) - return err + return result, err } r.AuditLogAuthorized(ae) - return nil + return result, nil } // GetByID returns a recipient group by ID diff --git a/frontend/src/routes/recipient/+page.svelte b/frontend/src/routes/recipient/+page.svelte index 16e1d6c..8488033 100644 --- a/frontend/src/routes/recipient/+page.svelte +++ b/frontend/src/routes/recipient/+page.svelte @@ -22,6 +22,7 @@ import { getPaginatedChunkWithParams } from '$lib/service/paginationChunk'; import CheckboxField from '$lib/components/CheckboxField.svelte'; import BigButton from '$lib/components/BigButton.svelte'; + import Button from '$lib/components/Button.svelte'; import FormColumns from '$lib/components/FormColumns.svelte'; import FormColumn from '$lib/components/FormColumn.svelte'; import FormFooter from '$lib/components/FormFooter.svelte'; @@ -63,6 +64,8 @@ ignoreOverwriteEmptyFields: true }; let csvSkippedRows = []; + let importResult = null; + let isImportResultModalVisible = false; const tableImportParams = newTableParams({ sortBy: 'email' }); let selectedRecipientsImportPaginatedChunk = []; let isImportModalVisible = false; @@ -210,8 +213,29 @@ importModalError = res.error; return; } - addToast('Recipients imported', 'Success'); + + // store import result for display + importResult = res.data; + + // build summary message + const summary = res.data.summary; + let message = `Import complete: ${summary.success} succeeded (${summary.created} created, ${summary.updated} updated)`; + if (summary.failed > 0) { + message += `, ${summary.failed} failed`; + } + if (csvSkippedRows.length > 0) { + message += `, ${csvSkippedRows.length} skipped in CSV`; + } + + console.log(summary); + addToast( + 'Import finished', + summary.failed > 0 || csvSkippedRows.length > 0 ? 'Warning' : 'Success' + ); + + // show result modal closeImportModal(); + isImportResultModalVisible = true; refreshRecipients(); } catch (err) { addToast('Failed to import recipients', 'Error'); @@ -289,6 +313,7 @@ const openImportModal = () => { csvSkippedRows = []; importModalError = ''; + importResult = null; isImportModalVisible = true; }; @@ -685,4 +710,232 @@ onClick={() => onClickDelete(deleteValues.id)} bind:isVisible={isDeleteAlertVisible} > + + {#if isImportResultModalVisible && importResult} + +
+
+ +
+
+

Recipients

+
    +
  • Total: {importResult.summary.total}
  • +
  • Created: {importResult.summary.created}
  • +
  • Updated: {importResult.summary.updated}
  • +
  • Failed: {importResult.summary.failed}
  • + {#if csvSkippedRows.length > 0} +
  • Skipped in CSV: {csvSkippedRows.length}
  • + {/if} +
+
+
+ + +
+
+ {#if importResult.createdRecipients?.length > 0} +
+
+ + + + + Created ({importResult.createdRecipients.length}) + +
+
+ {#each importResult.createdRecipients as recipient} +
+ + {recipient.email} + + {#if recipient.firstName || recipient.lastName} + + {recipient.firstName || ''} + {recipient.lastName || ''} + + {/if} +
+ {/each} +
+
+
+
+ {/if} + + {#if importResult.updatedRecipients?.length > 0} +
+
+ + + + + Updated ({importResult.updatedRecipients.length}) + +
+
+ {#each importResult.updatedRecipients as recipient} +
+ + {recipient.email} + + {#if recipient.firstName || recipient.lastName} + + {recipient.firstName || ''} + {recipient.lastName || ''} + + {/if} +
+ {/each} +
+
+
+
+ {/if} + + {#if csvSkippedRows.length > 0} +
+
+ + + + + Skipped in CSV ({csvSkippedRows.length}) + +
+

+ These rows were skipped during CSV parsing (before import) +

+
+ {#each csvSkippedRows as skip} +
+ + Line {skip.line}: {skip.reason} + {#if skip.row?.email} + ({skip.row.email}) + {/if} + +
+ {/each} +
+
+
+
+ {/if} + + {#if importResult.failures?.length > 0} +
+
+ + + + + Import Errors ({importResult.failures.length}) + +
+

+ These recipients failed to import (backend errors) +

+
+ {#each importResult.failures as err} +
+ + {err.email}: {err.reason} + +
+ {/each} +
+
+
+
+ {/if} +
+
+ +
+ +
+
+
+
+ {/if} diff --git a/frontend/src/routes/recipient/group/[id]/+page.svelte b/frontend/src/routes/recipient/group/[id]/+page.svelte index 4f6cfa4..8b5bbc5 100644 --- a/frontend/src/routes/recipient/group/[id]/+page.svelte +++ b/frontend/src/routes/recipient/group/[id]/+page.svelte @@ -24,6 +24,7 @@ import { parseCSVToRecipients } from '$lib/utils/csv'; import CheckboxField from '$lib/components/CheckboxField.svelte'; import BigButton from '$lib/components/BigButton.svelte'; + import Button from '$lib/components/Button.svelte'; import FormColumns from '$lib/components/FormColumns.svelte'; import FormColumn from '$lib/components/FormColumn.svelte'; import Table from '$lib/components/table/Table.svelte'; @@ -62,6 +63,8 @@ ignoreOverwriteEmptyFields: true }; let csvSkippedRows = []; + let importResult = null; + let isImportResultModalVisible = false; const tableImportParams = newTableParams({ sortBy: 'email' }); let selectedRecipientsImportPaginatedChunk = []; let isImportModalVisible = false; @@ -264,8 +267,29 @@ importError = res.error; return; } - addToast('Recipients imported to group', 'Success'); + + // store import result for display + importResult = res.data; + + // build summary message + const summary = res.data.summary; + let message = `Import complete: ${summary.success} succeeded (${summary.created} created, ${summary.updated} updated)`; + if (summary.failed > 0) { + message += `, ${summary.failed} failed`; + } + if (csvSkippedRows.length > 0) { + message += `, ${csvSkippedRows.length} skipped in CSV`; + } + + console.log(summary); + addToast( + 'Import finished', + summary.failed > 0 || csvSkippedRows.length > 0 ? 'Warning' : 'Success' + ); + + // show result modal closeImportModal(); + isImportResultModalVisible = true; refreshRecipients(); } catch (err) { addToast('Failed to import recipients to group', 'Error'); @@ -286,6 +310,7 @@ // track skipped rows if (result.skipped && result.skipped.length > 0) { + csvSkippedRows = csvSkippedRows.concat(result.skipped); console.info(`CSV import: ${result.skipped.length} rows skipped`, result.skipped); } @@ -353,6 +378,7 @@ const openImportModal = () => { csvSkippedRows = []; importError = ''; + importResult = null; isImportModalVisible = true; }; @@ -581,4 +607,232 @@ onClick={() => onClickRemoveRecipient(deleteValues.id)} bind:isVisible={isDeleteAlertVisible} > + + {#if isImportResultModalVisible && importResult} + +
+
+ +
+
+

Recipients

+
    +
  • Total: {importResult.summary.total}
  • +
  • Created: {importResult.summary.created}
  • +
  • Updated: {importResult.summary.updated}
  • +
  • Failed: {importResult.summary.failed}
  • + {#if csvSkippedRows.length > 0} +
  • Skipped in CSV: {csvSkippedRows.length}
  • + {/if} +
+
+
+ + +
+
+ {#if importResult.createdRecipients?.length > 0} +
+
+ + + + + Created ({importResult.createdRecipients.length}) + +
+
+ {#each importResult.createdRecipients as recipient} +
+ + {recipient.email} + + {#if recipient.firstName || recipient.lastName} + + {recipient.firstName || ''} + {recipient.lastName || ''} + + {/if} +
+ {/each} +
+
+
+
+ {/if} + + {#if importResult.updatedRecipients?.length > 0} +
+
+ + + + + Updated ({importResult.updatedRecipients.length}) + +
+
+ {#each importResult.updatedRecipients as recipient} +
+ + {recipient.email} + + {#if recipient.firstName || recipient.lastName} + + {recipient.firstName || ''} + {recipient.lastName || ''} + + {/if} +
+ {/each} +
+
+
+
+ {/if} + + {#if csvSkippedRows.length > 0} +
+
+ + + + + Skipped in CSV ({csvSkippedRows.length}) + +
+

+ These rows were skipped during CSV parsing (before import) +

+
+ {#each csvSkippedRows as skip} +
+ + Line {skip.line}: {skip.reason} + {#if skip.row?.email} + ({skip.row.email}) + {/if} + +
+ {/each} +
+
+
+
+ {/if} + + {#if importResult.failures?.length > 0} +
+
+ + + + + Import Errors ({importResult.failures.length}) + +
+

+ These recipients failed to import (backend errors) +

+
+ {#each importResult.failures as err} +
+ + {err.email}: {err.reason} + +
+ {/each} +
+
+
+
+ {/if} +
+
+ +
+ +
+
+
+
+ {/if}