add orphans recipients page and delete all orphaned

Signed-off-by: Ronni Skansing <rskansing@gmail.com>
This commit is contained in:
Ronni Skansing
2025-10-14 20:26:27 +02:00
parent 3740322ad6
commit c7910cbafb
7 changed files with 494 additions and 1 deletions
+4
View File
@@ -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).
+54
View File
@@ -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)
+123
View File
@@ -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,
+64
View File
@@ -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,
+27
View File
@@ -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<ApiResponse>}
*/
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<ApiResponse>}
*/
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
*
@@ -219,7 +219,10 @@
<HeadTitle title="Groups" />
<main>
<Headline>Groups</Headline>
<BigButton on:click={openCreateModal}>New group</BigButton>
<div class="flex gap-4 mb-4">
<BigButton on:click={openCreateModal}>New group</BigButton>
<BigButton on:click={() => goto('/recipient/orphaned/')}>View Orphaned</BigButton>
</div>
<Table
columns={[
{ column: 'Name', size: 'large' },
@@ -0,0 +1,218 @@
<script>
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;
};
</script>
<HeadTitle title="Orphaned Recipients" />
<section>
<Headline>Orphaned Recipients</Headline>
<div class="flex gap-4 mb-4">
<BigButton on:click={() => goto('/recipient/group/')}>Back to Groups</BigButton>
{#if recipients.length > 0}
<button
on:click={openDeleteAllAlert}
class="self-start mt-6 bg-gradient-to-b from-red-500 to-red-600 dark:from-red-600 dark:to-red-700 px-4 w-64 py-2 hover:from-red-400 hover:to-red-500 dark:hover:from-red-500 dark:hover:to-red-600 text-white font-bold uppercase rounded-md mb-10 transition-all duration-200"
>
Delete All Orphans
</button>
{/if}
</div>
<Table
isGhost={isRecipientsTableLoading}
columns={[
{ column: 'Email', size: 'small' },
{ column: 'First name', size: 'small' },
{ column: 'Last name', size: 'small' },
{ column: 'Phone', size: 'small' },
{ column: 'Extra identifier', size: 'small' },
{ column: 'Position', size: 'small' },
{ column: 'Repeat offender', size: 'small', alignText: 'center' },
{ column: 'Department', size: 'small' },
{ column: 'City', size: 'small' },
{ column: 'Country', size: 'small' },
{ column: 'Misc', size: 'small' }
]}
sortable={[
'first name',
'last name',
'extra identifier',
'email',
'phone',
'repeat offender',
'position',
'department',
'city',
'country',
'misc'
]}
hasData={!!recipients.length}
plural="recipients"
pagination={tableURLParams}
>
{#each recipients as recipient}
<TableRow>
<TableCellLink href={`/recipient/${recipient.id}`} title={recipient.email}>
{#if recipient.email}
{recipient.email}
{/if}
</TableCellLink>
<TableCell value={recipient.firstName} />
<TableCell value={recipient.lastName} />
<TableCell value={recipient.phone} />
<TableCell value={recipient.extraIdentifier} />
<TableCell value={recipient.position} />
<TableCellCheck value={recipient.isRepeatOffender} />
<TableCell value={recipient.department} />
<TableCell value={recipient.city} />
<TableCell value={recipient.country} />
<TableCell value={recipient.misc} />
<TableCellEmpty />
<TableCellAction>
<TableDropDownEllipsis>
<TableViewButton
on:click={() => {
goto(`/recipient/${recipient.id}`);
}}
/>
<TableDeleteButton on:click={() => openDeleteAlert(recipient)} />
</TableDropDownEllipsis>
</TableCellAction>
</TableRow>
{/each}
</Table>
<DeleteAlert
bind:isVisible={isDeleteAlertVisible}
name={deleteValues.email}
onClick={() => onClickDelete(deleteValues.id)}
title={deleteValues.title}
/>
<DeleteAlert
bind:isVisible={isDeleteAllAlertVisible}
name="all orphaned recipients"
onClick={onDeleteAllOrphaned}
title="Delete All Orphaned Recipients"
list={[
'This will permanently delete all recipients not assigned to any group',
'This action cannot be undone',
'All recipient data and statistics will be lost'
]}
confirm
/>
</section>