From c7910cbafb10ac32373fd7a75639551a9c7704bf Mon Sep 17 00:00:00 2001 From: Ronni Skansing Date: Tue, 14 Oct 2025 20:26:27 +0200 Subject: [PATCH] add orphans recipients page and delete all orphaned Signed-off-by: Ronni Skansing --- backend/app/administration.go | 4 + backend/controller/recipient.go | 54 +++++ backend/repository/recipient.go | 123 ++++++++++ backend/service/recipient.go | 64 +++++ frontend/src/lib/api/api.js | 27 +++ .../src/routes/recipient/group/+page.svelte | 5 +- .../routes/recipient/orphaned/+page.svelte | 218 ++++++++++++++++++ 7 files changed, 494 insertions(+), 1 deletion(-) create mode 100644 frontend/src/routes/recipient/orphaned/+page.svelte diff --git a/backend/app/administration.go b/backend/app/administration.go index f3ff38f..4b3d59b 100644 --- a/backend/app/administration.go +++ b/backend/app/administration.go @@ -101,6 +101,8 @@ const ( ROUTE_V1_RECIPIENT_ID_EVENTS = "/api/v1/recipient/:id/events" ROUTE_V1_RECIPIENT_ID_STATS = "/api/v1/recipient/:id/stats" ROUTE_V1_RECIPIENT_REPEAT_OFFENDERS = "/api/v1/recipient/repeat-offenders" + ROUTE_V1_RECIPIENT_ORPHANED = "/api/v1/recipient/orphaned" + ROUTE_V1_RECIPIENT_ORPHANED_DELETE = "/api/v1/recipient/orphaned/delete" ROUTE_V1_RECIPIENT_GROUP = "/api/v1/recipient/group" ROUTE_V1_RECIPIENT_GROUP_ID = "/api/v1/recipient/group/:id" ROUTE_V1_RECIPIENT_GROUP_ID_IMPORT = "/api/v1/recipient/group/:id/import" @@ -308,6 +310,8 @@ func setupRoutes( PATCH(ROUTE_V1_RECIPIENT_ID, middleware.SessionHandler, controllers.Recipient.UpdateByID). DELETE(ROUTE_V1_RECIPIENT_ID, middleware.SessionHandler, controllers.Recipient.DeleteByID). GET(ROUTE_V1_RECIPIENT_REPEAT_OFFENDERS, middleware.SessionHandler, controllers.Recipient.GetRepeatOffenderCount). + GET(ROUTE_V1_RECIPIENT_ORPHANED, middleware.SessionHandler, controllers.Recipient.GetOrphaned). + DELETE(ROUTE_V1_RECIPIENT_ORPHANED_DELETE, middleware.SessionHandler, controllers.Recipient.DeleteAllOrphaned). // recipient group GET(ROUTE_V1_RECIPIENT_GROUP, middleware.SessionHandler, controllers.RecipientGroup.GetAll). GET(ROUTE_V1_RECIPIENT_GROUP_ID, middleware.SessionHandler, controllers.RecipientGroup.GetByID). diff --git a/backend/controller/recipient.go b/backend/controller/recipient.go index c78886d..6029197 100644 --- a/backend/controller/recipient.go +++ b/backend/controller/recipient.go @@ -306,6 +306,60 @@ func (r *Recipient) GetRepeatOffenderCount(g *gin.Context) { r.Response.OK(g, count) } +// GetOrphaned gets all recipients that are not in any group +func (r *Recipient) GetOrphaned(g *gin.Context) { + session, _, ok := r.handleSession(g) + if !ok { + return + } + // parse request + companyID := companyIDFromRequestQuery(g) + queryArgs, ok := r.handleQueryArgs(g) + if !ok { + return + } + queryArgs.DefaultSortBy("first_name") + // remap query args + queryArgs.RemapOrderBy(recipientColumnByMap) + // get orphaned recipients + recipients, err := r.RecipientService.GetOrphaned( + g.Request.Context(), + companyID, + session, + &repository.RecipientOption{ + QueryArgs: queryArgs, + }, + ) + // handle response + if ok := r.handleErrors(g, err); !ok { + return + } + r.Response.OK(g, recipients) +} + +// DeleteAllOrphaned deletes all recipients that are not in any group +func (r *Recipient) DeleteAllOrphaned(g *gin.Context) { + session, _, ok := r.handleSession(g) + if !ok { + return + } + // parse request + companyID := companyIDFromRequestQuery(g) + // delete orphaned recipients + count, err := r.RecipientService.DeleteAllOrphaned( + g.Request.Context(), + companyID, + session, + ) + // handle response + if ok := r.handleErrors(g, err); !ok { + return + } + r.Response.OK(g, gin.H{ + "count": count, + }) +} + // GetAll gets all recipients func (r *Recipient) GetAll(g *gin.Context) { session, _, ok := r.handleSession(g) diff --git a/backend/repository/recipient.go b/backend/repository/recipient.go index 542904a..9a03e20 100644 --- a/backend/repository/recipient.go +++ b/backend/repository/recipient.go @@ -336,6 +336,129 @@ func (r *Recipient) GetAllCampaignEvents( return result, nil } +// GetOrphaned gets all recipients that are not in any group +func (r *Recipient) GetOrphaned( + ctx context.Context, + companyID *uuid.UUID, + options *RecipientOption, +) (*model.Result[model.Recipient], error) { + result := model.NewEmptyResult[model.Recipient]() + + // build optimized LEFT JOIN query for orphaned recipients + var companyFilter string + var args []interface{} + + if companyID != nil { + companyFilter = fmt.Sprintf("AND %s.company_id = ?", database.RECIPIENT_TABLE) + args = append(args, companyID) + } else { + companyFilter = fmt.Sprintf("AND %s.company_id IS NULL", database.RECIPIENT_TABLE) + } + + query := fmt.Sprintf(` + SELECT %s.* FROM %s + LEFT JOIN %s rgr ON %s.id = rgr.recipient_id + WHERE rgr.recipient_id IS NULL %s`, + database.RECIPIENT_TABLE, + database.RECIPIENT_TABLE, + database.RECIPIENT_GROUP_RECIPIENT_TABLE, + database.RECIPIENT_TABLE, + companyFilter, + ) + + // apply query args for sorting/pagination if provided + if options.QueryArgs != nil { + if options.QueryArgs.OrderBy != "" { + direction := "ASC" + if options.QueryArgs.Desc { + direction = "DESC" + } + query += fmt.Sprintf(" ORDER BY %s %s", options.QueryArgs.OrderBy, direction) + } + if options.QueryArgs.Limit > 0 { + query += fmt.Sprintf(" LIMIT %d OFFSET %d", options.QueryArgs.Limit, options.QueryArgs.Offset) + } + } + + var rows []database.Recipient + dbRes := r.DB.Raw(query, args...).Find(&rows) + + if dbRes.Error != nil { + return result, dbRes.Error + } + + // check for next page using raw query + if options.QueryArgs != nil && options.QueryArgs.Limit > 0 { + countQuery := fmt.Sprintf(` + SELECT COUNT(*) FROM %s + LEFT JOIN %s rgr ON %s.id = rgr.recipient_id + WHERE rgr.recipient_id IS NULL %s`, + database.RECIPIENT_TABLE, + database.RECIPIENT_GROUP_RECIPIENT_TABLE, + database.RECIPIENT_TABLE, + companyFilter, + ) + + var totalCount int64 + if err := r.DB.Raw(countQuery, args...).Count(&totalCount).Error; err != nil { + return result, errs.Wrap(err) + } + + offset64 := int64(options.QueryArgs.Offset) + limit64 := int64(options.QueryArgs.Limit) + result.HasNextPage = totalCount > (offset64 + limit64) + } + + for _, recipient := range rows { + r, err := ToRecipient(&recipient) + if err != nil { + return result, errs.Wrap(err) + } + result.Rows = append(result.Rows, r) + } + + return result, nil +} + +// DeleteAllOrphaned deletes all recipients that are not in any group +func (r *Recipient) DeleteAllOrphaned( + ctx context.Context, + companyID *uuid.UUID, +) (int64, error) { + // build optimized LEFT JOIN delete query for orphaned recipients + var companyFilter string + var args []interface{} + + if companyID != nil { + companyFilter = fmt.Sprintf("AND r.company_id = ?") + args = append(args, companyID) + } else { + companyFilter = fmt.Sprintf("AND r.company_id IS NULL") + } + + // use raw SQL for optimized LEFT JOIN delete + query := fmt.Sprintf(` + DELETE FROM %s + WHERE id IN ( + SELECT r.id FROM %s r + LEFT JOIN %s rgr ON r.id = rgr.recipient_id + WHERE rgr.recipient_id IS NULL %s + )`, + database.RECIPIENT_TABLE, + database.RECIPIENT_TABLE, + database.RECIPIENT_GROUP_RECIPIENT_TABLE, + companyFilter, + ) + + result := r.DB.Exec(query, args...) + + if result.Error != nil { + return 0, result.Error + } + + return result.RowsAffected, nil +} + // GetByID gets a recipient by id func (r *Recipient) GetByID( ctx context.Context, diff --git a/backend/service/recipient.go b/backend/service/recipient.go index 7b6fbb6..b08d73b 100644 --- a/backend/service/recipient.go +++ b/backend/service/recipient.go @@ -386,6 +386,70 @@ func (r *Recipient) GetRepeatOffenderCount( return count, nil } +// GetOrphaned gets all recipients that are not in any group +func (r *Recipient) GetOrphaned( + ctx context.Context, + companyID *uuid.UUID, // can be null + session *model.Session, + options *repository.RecipientOption, +) (*model.Result[model.Recipient], error) { + result := model.NewEmptyResult[model.Recipient]() + ae := NewAuditEvent("Recipient.GetOrphaned", session) + // check permissions + isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL) + if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) { + r.LogAuthError(err) + return result, errs.Wrap(err) + } + if !isAuthorized { + r.AuditLogNotAuthorized(ae) + return result, errs.ErrAuthorizationFailed + } + // get orphaned recipients + result, err = r.RecipientRepository.GetOrphaned( + ctx, + companyID, + options, + ) + if err != nil { + r.Logger.Errorw("failed to get orphaned recipients - failed to get orphaned recipients", "error", err) + return result, errs.Wrap(err) + } + // no audit on read + return result, nil +} + +// DeleteAllOrphaned deletes all recipients that are not in any group +func (r *Recipient) DeleteAllOrphaned( + ctx context.Context, + companyID *uuid.UUID, // can be null + session *model.Session, +) (int64, error) { + ae := NewAuditEvent("Recipient.DeleteAllOrphaned", session) + // check permissions + isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL) + if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) { + r.LogAuthError(err) + return 0, errs.Wrap(err) + } + if !isAuthorized { + r.AuditLogNotAuthorized(ae) + return 0, errs.ErrAuthorizationFailed + } + // delete orphaned recipients + count, err := r.RecipientRepository.DeleteAllOrphaned( + ctx, + companyID, + ) + if err != nil { + r.Logger.Errorw("failed to delete orphaned recipients - failed to delete orphaned recipients", "error", err) + return 0, errs.Wrap(err) + } + ae.Details["count"] = count + r.AuditLogAuthorized(ae) + return count, nil +} + // GetByEmail gets a recipient by email func (r *Recipient) GetByEmail( ctx context.Context, diff --git a/frontend/src/lib/api/api.js b/frontend/src/lib/api/api.js index a943469..7585798 100644 --- a/frontend/src/lib/api/api.js +++ b/frontend/src/lib/api/api.js @@ -2048,6 +2048,33 @@ export class API { ); }, + /** + * Get all orphaned recipients (recipients not in any group) using pagination. + * + * @param {TableURLParams} options + * @param {string|null} companyID + * @returns {Promise} + */ + getOrphaned: async (options, companyID = null) => { + return await getJSON( + this.getPath( + `/recipient/orphaned?${appendQuery(options)}${this.appendCompanyQuery(companyID)}` + ) + ); + }, + + /** + * Delete all orphaned recipients (recipients not in any group). + * + * @param {string|null} companyID + * @returns {Promise} + */ + deleteAllOrphaned: async (companyID = null) => { + return await deleteReq( + this.getPath(`/recipient/orphaned/delete?${this.appendCompanyQuery(companyID)}`) + ); + }, + /** * Get campaign events related by recipient id and optional campaign id * diff --git a/frontend/src/routes/recipient/group/+page.svelte b/frontend/src/routes/recipient/group/+page.svelte index 50c63fe..5908eb2 100644 --- a/frontend/src/routes/recipient/group/+page.svelte +++ b/frontend/src/routes/recipient/group/+page.svelte @@ -219,7 +219,10 @@
Groups - New group +
+ New group + goto('/recipient/orphaned/')}>View Orphaned +
+ import { onMount } from 'svelte'; + import HeadTitle from '$lib/components/HeadTitle.svelte'; + import Headline from '$lib/components/Headline.svelte'; + import BigButton from '$lib/components/BigButton.svelte'; + import Table from '$lib/components/table/Table.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'; + import TableCellCheck from '$lib/components/table/TableCellCheck.svelte'; + import TableCellEmpty from '$lib/components/table/TableCellEmpty.svelte'; + import TableCellAction from '$lib/components/table/TableCellAction.svelte'; + import TableDropDownEllipsis from '$lib/components/table/TableDropDownEllipsis.svelte'; + import TableViewButton from '$lib/components/table/TableViewButton.svelte'; + import TableDeleteButton from '$lib/components/table/TableDeleteButton2.svelte'; + import DeleteAlert from '$lib/components/modal/DeleteAlert.svelte'; + import { AppStateService } from '$lib/service/appState'; + import { addToast } from '$lib/store/toast'; + import { newTableURLParams } from '$lib/service/tableURLParams.js'; + import { api } from '$lib/api/apiProxy.js'; + import { goto } from '$app/navigation'; + + // state + const appStateService_ = AppStateService.instance; + + const tableURLParams = newTableURLParams(); + let contextCompanyID = null; + let recipients = []; + + let isDeleteAlertVisible = false; + let deleteValues = { + title: 'Delete Recipient', + id: null, + email: null + }; + + let isRecipientsTableLoading = false; + let isDeleteAllAlertVisible = false; + + // hooks + onMount(() => { + const context = appStateService_.getContext(); + if (context) { + contextCompanyID = context.companyID; + } + refreshRecipients(); + tableURLParams.onChange(refreshRecipients); + + return () => { + tableURLParams.unsubscribe(); + }; + }); + + // component logic + const refreshRecipients = async () => { + isRecipientsTableLoading = true; + try { + const res = await api.recipient.getOrphaned(tableURLParams, contextCompanyID); + if (res.success) { + recipients = res.data.rows; + return; + } + throw res.error; + } catch (e) { + addToast('Failed to load orphaned recipients', 'Error'); + console.error('failed to load orphaned recipients', e); + } finally { + isRecipientsTableLoading = false; + } + }; + + const onDeleteAllOrphaned = async () => { + try { + const res = await api.recipient.deleteAllOrphaned(contextCompanyID); + if (res.success) { + addToast(`Deleted ${res.data.count} orphaned recipients`, 'Success'); + refreshRecipients(); + return res; // Return the success response + } + addToast('Failed to delete orphaned recipients', 'Error'); + throw res.error; + } catch (e) { + addToast('Failed to delete orphaned recipients', 'Error'); + console.error('failed to delete orphaned recipients:', e); + throw e; // Re-throw the error for DeleteAlert to handle + } + }; + + const openDeleteAlert = (recipient) => { + deleteValues.id = recipient.id; + deleteValues.email = recipient.email; + isDeleteAlertVisible = true; + }; + + const onClickDelete = async (id) => { + const action = api.recipient.delete(id); + action + .then((res) => { + if (res.success) { + addToast('Recipient deleted successfully', 'Success'); + refreshRecipients(); + return; + } + throw res.error; + }) + .catch((e) => { + addToast('Failed to delete recipient', 'Error'); + console.error('failed to delete recipient:', e); + }); + return action; + }; + + const openDeleteAllAlert = () => { + isDeleteAllAlertVisible = true; + }; + + + + +
+ Orphaned Recipients + +
+ goto('/recipient/group/')}>Back to Groups + {#if recipients.length > 0} + + {/if} +
+ +
+ {#each recipients as recipient} + + + {#if recipient.email} + {recipient.email} + {/if} + + + + + + + + + + + + + + + { + goto(`/recipient/${recipient.id}`); + }} + /> + openDeleteAlert(recipient)} /> + + + + {/each} +
+ + onClickDelete(deleteValues.id)} + title={deleteValues.title} + /> + + +