mirror of
https://github.com/phishingclub/phishingclub.git
synced 2026-08-17 16:07:18 +02:00
add orphans recipients page and delete all orphaned
Signed-off-by: Ronni Skansing <rskansing@gmail.com>
This commit is contained in:
@@ -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).
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user