Add bulk deletion (by id or query) to lists and campaigns.

- Like subscribers, select one-or more or 'all' items and delete them
  on the lists and campaigns UIs.
- New `DELETE /api/lists` and `DELETE /api/campaigns` endpoints that
  take one or more `id` params or a single `query` param.
This commit is contained in:
Kailash Nadh
2025-11-23 22:21:20 +05:30
parent 2b60907338
commit 583f92a6fc
16 changed files with 409 additions and 39 deletions

View File

@@ -423,6 +423,54 @@ func (a *App) DeleteCampaign(c echo.Context) error {
return c.JSON(http.StatusOK, okResp{true})
}
// DeleteCampaigns deletes multiple campaigns by IDs or by query.
func (a *App) DeleteCampaigns(c echo.Context) error {
// Get the authenticated user.
user := auth.GetUser(c)
var (
hasAllPerm = user.HasPerm(auth.PermCampaignsManageAll)
permittedLists []int
)
if !hasAllPerm {
// Either the user has campaigns:manage_all permissions and can manage all campaigns,
// or the campaigns are filtered by the lists the user has get|manage access to.
hasAllPerm, permittedLists = user.GetPermittedLists(auth.PermTypeGet | auth.PermTypeManage)
}
var (
ids []int
query string
)
// Check for IDs in query params.
if len(c.Request().URL.Query()["id"]) > 0 {
var err error
ids, err = parseStringIDs(c.Request().URL.Query()["id"])
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest,
a.i18n.Ts("globals.messages.errorInvalidIDs", "error", err.Error()))
}
} else {
// Check for query param.
query = strings.TrimSpace(c.FormValue("query"))
}
// Validate that either IDs or query is provided.
if len(ids) == 0 && query == "" {
return echo.NewHTTPError(http.StatusBadRequest,
a.i18n.Ts("globals.messages.errorInvalidIDs", "error", "id or query required"))
}
// Delete the campaigns from the DB.
if err := a.core.DeleteCampaigns(ids, query, hasAllPerm, permittedLists); err != nil {
return err
}
return c.JSON(http.StatusOK, okResp{true})
}
// GetRunningCampaignStats returns stats of a given set of campaign IDs.
func (a *App) GetRunningCampaignStats(c echo.Context) error {
// Get the running campaign stats from the DB.

View File

@@ -153,7 +153,8 @@ func initHTTPHandlers(e *echo.Echo, a *App) {
g.GET("/api/lists/:id", hasID(a.GetList))
g.POST("/api/lists", pm(a.CreateList, "lists:manage_all"))
g.PUT("/api/lists/:id", hasID(a.UpdateList))
g.DELETE("/api/lists/:id", hasID(a.DeleteLists))
g.DELETE("/api/lists", a.DeleteLists)
g.DELETE("/api/lists/:id", hasID(a.DeleteList))
g.GET("/api/campaigns", pm(a.GetCampaigns, "campaigns:get_all", "campaigns:get"))
g.GET("/api/campaigns/running/stats", pm(a.GetRunningCampaignStats, "campaigns:get_all", "campaigns:get"))
@@ -169,6 +170,7 @@ func initHTTPHandlers(e *echo.Echo, a *App) {
g.PUT("/api/campaigns/:id", pm(hasID(a.UpdateCampaign), "campaigns:manage_all", "campaigns:manage"))
g.PUT("/api/campaigns/:id/status", pm(hasID(a.UpdateCampaignStatus), "campaigns:manage_all", "campaigns:manage"))
g.PUT("/api/campaigns/:id/archive", pm(hasID(a.UpdateCampaignArchive), "campaigns:manage_all", "campaigns:manage"))
g.DELETE("/api/campaigns", pm(a.DeleteCampaigns, "campaigns:manage", "campaigns:manage_all"))
g.DELETE("/api/campaigns/:id", pm(hasID(a.DeleteCampaign), "campaigns:manage_all", "campaigns:manage"))
g.GET("/api/media", pm(a.GetAllMedia, "media:get"))

View File

@@ -143,33 +143,73 @@ func (a *App) UpdateList(c echo.Context) error {
return c.JSON(http.StatusOK, okResp{out})
}
// DeleteLists handles list deletion, either a single one (ID in the URI), or a list.
// It's permission checked by the listPerm middleware.
func (a *App) DeleteLists(c echo.Context) error {
// Get the authenticated user.
// DeleteList deletes a single list by ID.
func (a *App) DeleteList(c echo.Context) error {
id := getID(c)
// Check if the user has manage permission for the list.
user := auth.GetUser(c)
var (
id, _ = strconv.ParseInt(c.Param("id"), 10, 64)
ids []int
)
if id < 1 && len(ids) == 0 {
return echo.NewHTTPError(http.StatusBadRequest, a.i18n.T("globals.messages.invalidID"))
}
if id > 0 {
ids = append(ids, int(id))
}
// Check if the user has access to the list.
if err := user.HasListPerm(auth.PermTypeManage, ids...); err != nil {
if err := user.HasListPerm(auth.PermTypeManage, id); err != nil {
return err
}
// Delete the lists from the DB.
if err := a.core.DeleteLists(ids); err != nil {
// Delete the list from the DB.
// Pass getAll=true since we've already verified permissions above.
if err := a.core.DeleteLists([]int{id}, "", true, nil); err != nil {
return err
}
return c.JSON(http.StatusOK, okResp{true})
}
// DeleteLists deletes multiple lists by IDs or by query.
func (a *App) DeleteLists(c echo.Context) error {
user := auth.GetUser(c)
var (
ids []int
query string
)
// Check for IDs in query params.
if len(c.Request().URL.Query()["id"]) > 0 {
var err error
ids, err = parseStringIDs(c.Request().URL.Query()["id"])
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest,
a.i18n.Ts("globals.messages.errorInvalidIDs", "error", err.Error()))
}
} else {
// Check for query param.
query = strings.TrimSpace(c.FormValue("query"))
}
// Validate that either IDs or query is provided.
if len(ids) == 0 && query == "" {
return echo.NewHTTPError(http.StatusBadRequest,
a.i18n.Ts("globals.messages.errorInvalidIDs", "error", "id or query required"))
}
// For ID deletion, check if the user has manage permission for the specific lists.
if len(ids) > 0 {
if err := user.HasListPerm(auth.PermTypeManage, ids...); err != nil {
return err
}
// Delete the lists from the DB.
// Pass getAll=true since we've already verified permissions above.
if err := a.core.DeleteLists(ids, "", true, nil); err != nil {
return err
}
} else {
// For query deletion, get the list IDs the user has manage permission for.
hasAllPerm, permittedIDs := user.GetPermittedLists(auth.PermTypeManage)
// Delete the lists from the DB with permission filtering.
if err := a.core.DeleteLists(nil, query, hasAllPerm, permittedIDs); err != nil {
return err
}
}
return c.JSON(http.StatusOK, okResp{true})
}

View File

@@ -13,6 +13,7 @@
| PUT | [/api/campaigns/{campaign_id}/status](#put-apicampaignscampaign_idstatus) | Change status of a campaign. |
| PUT | [/api/campaigns/{campaign_id}/archive](#put-apicampaignscampaign_idarchive) | Publish campaign to public archive. |
| DELETE | [/api/campaigns/{campaign_id}](#delete-apicampaignscampaign_id) | Delete a campaign. |
| DELETE | [/api/campaigns](#delete-apicampaigns) | Delete multiple campaigns. |
____________________________________________________________________________________________________________________________________
@@ -506,3 +507,36 @@ curl -u "api_user:token" -X DELETE 'http://localhost:9000/api/campaigns/34'
"data": true
}
```
______________________________________________________________________
#### DELETE /api/campaigns
Delete multiple campaigns by IDs or by a search query.
##### Parameters
| Name | Type | Required | Description |
| :---- | :--------- | :---------------------------- | :--------------------------------------------------------------------------- |
| id | number\[\] | Yes (if `query` not provided) | Onr or more campaign IDs to delete. |
| query | string | Yes (if `id` not provided) | Fulltext search query to filter campaigns for deletion (same as GET query). |
##### Example Request (by IDs)
```shell
curl -u "api_user:token" -X DELETE 'http://localhost:9000/api/campaigns?id=10&id=11&id=12'
```
##### Example Request (by search query)
```shell
curl -u "api_user:token" -X DELETE 'http://localhost:9000/api/campaigns?query=test%20campaign'
```
##### Example Response
```json
{
"data": true
}
```

View File

@@ -8,6 +8,7 @@
| POST | [/api/lists](#post-apilists) | Create a new list. |
| PUT | [/api/lists/{list_id}](#put-apilistslist_id) | Update a list. |
| DELETE | [/api/lists/{list_id}](#delete-apilistslist_id) | Delete a list. |
| DELETE | [/api/lists](#delete-apilists) | Delete multiple lists. |
______________________________________________________________________
@@ -258,3 +259,38 @@ curl -u 'api_username:access_token' -X DELETE 'http://localhost:9000/api/lists/1
"data": true
}
```
______________________________________________________________________
#### DELETE /api/lists
Delete multiple lists by IDs or by a search query.
> **Note:** Users can only delete lists they have `manage` permission for. Any lists in the query that the user doesn't have permission to manage is ignored.
##### Parameters
| Name | Type | Required | Description |
| :---- | :--------- | :---------------------------- | :----------------------------------------------------------------- |
| id | number\[\] | Yes (if `query` not provided) | One or more list IDs to delete. |
| query | string | Yes (if `id` not provided) | Search query to filter lists for deletion (same as the GET query). |
##### Example Request (by IDs)
```shell
curl -u "api_user:token" -X DELETE 'http://localhost:9000/api/lists?id=10&id=11&id=12'
```
##### Example Request (by search query)
```shell
curl -u "api_user:token" -X DELETE 'http://localhost:9000/api/lists?query=test%20list'
```
##### Example Response
```json
{
"data": true
}
```

View File

@@ -153,6 +153,11 @@ export const deleteList = (id) => http.delete(
{ loading: models.lists },
);
export const deleteLists = (params) => http.delete(
'/api/lists',
{ params, loading: models.lists },
);
// Subscribers.
export const getSubscribers = async (params) => http.get(
'/api/subscribers',
@@ -353,6 +358,11 @@ export const deleteCampaign = async (id) => http.delete(
{ loading: models.campaigns },
);
export const deleteCampaigns = (params) => http.delete(
'/api/campaigns',
{ params, loading: models.campaigns },
);
// Media.
export const getMedia = async (params) => http.get(
'/api/media',

View File

@@ -17,9 +17,10 @@
</div>
</header>
<b-table :data="campaigns.results" :loading="loading.campaigns" :row-class="highlightedRow" paginated
backend-pagination pagination-position="both" @page-change="onPageChange" :current-page="queryParams.page"
:per-page="campaigns.perPage" :total="campaigns.total" hoverable backend-sorting @sort="onSort">
<b-table :data="campaigns.results" :loading="loading.campaigns" :row-class="highlightedRow"
@check-all="onTableCheck" @check="onTableCheck" :checked-rows.sync="bulk.checked" paginated backend-pagination
pagination-position="both" @page-change="onPageChange" :current-page="queryParams.page"
:per-page="campaigns.perPage" :total="campaigns.total" hoverable checkable backend-sorting @sort="onSort">
<template #top-left>
<div class="columns">
<div class="column is-6">
@@ -36,6 +37,20 @@
</form>
</div>
</div>
<div class="actions" v-if="bulk.checked.length > 0">
<a class="a" href="#" @click.prevent="deleteCampaigns" data-cy="btn-delete-campaigns">
<b-icon icon="trash-can-outline" size="is-small" /> Delete
</a>
<span class="a">
{{ $tc('globals.messages.numSelected', numSelectedCampaigns, { num: numSelectedCampaigns }) }}
<span v-if="!bulk.all && campaigns.total > campaigns.perPage">
&mdash;
<a href="#" @click.prevent="onSelectAll">
{{ $tc('globals.messages.selectAll', campaigns.total, { num: campaigns.total }) }}
</a>
</span>
</span>
</div>
</template>
<b-table-column v-slot="props" cell-class="status" field="status" :label="$t('globals.fields.status')" width="10%"
@@ -285,6 +300,12 @@ export default Vue.extend({
},
pollID: null,
campaignStatsData: {},
// Table bulk row selection states.
bulk: {
checked: [],
all: false,
},
};
},
@@ -456,10 +477,57 @@ export default Vue.extend({
this.$utils.toast(this.$t('globals.messages.deleted', { name: c.name }));
});
},
// Mark all campaigns in the query as selected.
onSelectAll() {
this.bulk.all = true;
},
onTableCheck() {
// Disable bulk.all selection if there are no rows checked in the table.
if (this.bulk.checked.length !== this.campaigns.total) {
this.bulk.all = false;
}
},
deleteCampaigns() {
const name = this.$tc('globals.terms.campaign', this.numSelectedCampaigns);
const fn = () => {
const params = {};
if (!this.bulk.all && this.bulk.checked.length > 0) {
// If 'all' is not selected, delete campaigns by IDs.
params.id = this.bulk.checked.map((c) => c.id);
} else {
// 'All' is selected, delete by query.
params.query = this.queryParams.query.replace(/[^\p{L}\p{N}\s]/gu, ' ');
}
this.$api.deleteCampaigns(params)
.then(() => {
this.getCampaigns();
this.$utils.toast(this.$tc(
'globals.messages.deletedCount',
this.numSelectedCampaigns,
{ num: this.numSelectedCampaigns, name },
));
});
};
this.$utils.confirm(this.$tc(
'globals.messages.confirmDelete',
this.numSelectedCampaigns,
{ num: this.numSelectedCampaigns, name: name.toLowerCase() },
), fn);
},
},
computed: {
...mapState(['campaigns', 'loading']),
numSelectedCampaigns() {
return this.bulk.all ? this.campaigns.total : this.bulk.checked.length;
},
},
mounted() {

View File

@@ -26,9 +26,10 @@
</div>
</header>
<b-table :data="lists.results" :loading="loading.listsFull" hoverable default-sort="createdAt" paginated
backend-pagination pagination-position="both" @page-change="onPageChange" :current-page="queryParams.page"
:per-page="lists.perPage" :total="lists.total" backend-sorting @sort="onSort">
<b-table :data="lists.results" :loading="loading.listsFull" @check-all="onTableCheck" @check="onTableCheck"
:checked-rows.sync="bulk.checked" hoverable default-sort="createdAt" paginated backend-pagination
pagination-position="both" @page-change="onPageChange" :current-page="queryParams.page" :per-page="lists.perPage"
:total="lists.total" checkable backend-sorting @sort="onSort">
<template #top-left>
<div class="columns">
<div class="column is-6">
@@ -42,6 +43,20 @@
</form>
</div>
</div>
<div class="actions" v-if="bulk.checked.length > 0">
<a class="a" href="#" @click.prevent="deleteLists" data-cy="btn-delete-lists">
<b-icon icon="trash-can-outline" size="is-small" /> Delete
</a>
<span class="a">
{{ $tc('globals.messages.numSelected', numSelectedLists, { num: numSelectedLists }) }}
<span v-if="!bulk.all && lists.total > lists.perPage">
&mdash;
<a href="#" @click.prevent="onSelectAll">
{{ $tc('globals.messages.selectAll', lists.total, { num: lists.total }) }}
</a>
</span>
</span>
</div>
</template>
<b-table-column v-slot="props" field="name" :label="$t('globals.fields.name')" header-class="cy-name" sortable
@@ -195,6 +210,12 @@ export default Vue.extend({
order: 'asc',
status: this.$route.query.status || 'active',
},
// Table bulk row selection states.
bulk: {
checked: [],
all: false,
},
};
},
@@ -272,6 +293,49 @@ export default Vue.extend({
);
},
// Mark all lists in the query as selected.
onSelectAll() {
this.bulk.all = true;
},
onTableCheck() {
// Disable bulk.all selection if there are no rows checked in the table.
if (this.bulk.checked.length !== this.lists.total) {
this.bulk.all = false;
}
},
deleteLists() {
const name = this.$tc('globals.terms.list', this.numSelectedCampaigns);
const fn = () => {
const params = {};
if (!this.bulk.all && this.bulk.checked.length > 0) {
// If 'all' is not selected, delete lists by IDs.
params.id = this.bulk.checked.map((l) => l.id);
} else {
// 'All' is selected, delete by query.
params.query = this.queryParams.query.replace(/[^\p{L}\p{N}\s]/gu, ' ');
}
this.$api.deleteLists(params)
.then(() => {
this.getLists();
this.$utils.toast(this.$tc(
'globals.messages.deletedCount',
this.numSelectedLists,
{ num: this.numSelectedLists, name },
));
});
};
this.$utils.confirm(this.$tc(
'globals.messages.confirmDelete',
this.numSelectedLists,
{ num: this.numSelectedLists, name: name.toLowerCase() },
), fn);
},
createOptinCampaign(list) {
const data = {
name: this.$t('lists.optinTo', { name: list.name }),
@@ -292,6 +356,10 @@ export default Vue.extend({
computed: {
...mapState(['loading', 'settings']),
numSelectedLists() {
return this.bulk.all ? this.lists.total : this.bulk.checked.length;
},
},
mounted() {

View File

@@ -9,7 +9,8 @@
</span>
<span v-if="currentList">
&raquo; {{ currentList.name }}
<span v-if="queryParams.subStatus" class="has-text-grey has-text-weight-normal is-capitalized">({{ queryParams.subStatus }})</span>
<span v-if="queryParams.subStatus" class="has-text-grey has-text-weight-normal is-capitalized">({{
queryParams.subStatus }})</span>
</span>
</h1>
</div>
@@ -92,11 +93,11 @@
<b-icon icon="account-off-outline" size="is-small" /> Blocklist
</a>
<span class="a">
{{ $t('subscribers.numSelected', { num: numSelectedSubscribers }) }}
{{ $t('globals.messages.numSelected', { num: numSelectedSubscribers }) }}
<span v-if="!bulk.all && subscribers.total > subscribers.perPage">
&mdash;
<a href="#" @click.prevent="selectAllSubscribers">
{{ $t('subscribers.selectAll', { num: subscribers.total }) }}
{{ $t('globals.messages.selectAll', { num: subscribers.total }) }}
</a>
</span>
</span>

View File

@@ -176,6 +176,8 @@
"globals.fields.type": "Type",
"globals.fields.updatedAt": "Updated",
"globals.fields.uuid": "UUID",
"globals.messages.selectAll": "Select all {num}",
"globals.messages.confirmDelete": "Delete {num} {name}?",
"globals.messages.numSelected": "{num} selected",
"globals.messages.confirm": "Are you sure?",
"globals.messages.confirmDiscard": "Discard changes?",

View File

@@ -330,6 +330,27 @@ func (c *Core) DeleteCampaign(id int) error {
return nil
}
// DeleteCampaigns deletes multiple campaigns by IDs or by query.
func (c *Core) DeleteCampaigns(ids []int, query string, hasAllPerm bool, permittedLists []int) error {
var queryStr string
if len(ids) > 0 {
queryStr = ""
} else if query != "" {
queryStr = makeSearchString(query)
} else {
return echo.NewHTTPError(http.StatusBadRequest, c.i18n.T("globals.messages.invalidData"))
}
if _, err := c.q.DeleteCampaigns.Exec(pq.Array(ids), queryStr, hasAllPerm, pq.Array(permittedLists)); err != nil {
c.log.Printf("error deleting campaigns: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorDeleting", "name", "{globals.terms.campaigns}", "error", pqErrMsg(err)))
}
return nil
}
// CampaignHasLists checks if a campaign has any of the given list IDs.
func (c *Core) CampaignHasLists(id int, listIDs []int) (bool, error) {
has := false

View File

@@ -136,9 +136,7 @@ func pqErrMsg(err error) string {
// query SQL statement (string interpolated) and returns the
// search query string along with the SQL expression.
func makeSearchQuery(searchStr, orderBy, order, query string, querySortFields []string) (string, string) {
if searchStr != "" {
searchStr = `%` + string(regexFullTextQuery.ReplaceAll([]byte(searchStr), []byte("&"))) + `%`
}
searchStr = makeSearchString(searchStr)
// Sort params.
if !strSliceContains(orderBy, querySortFields) {
@@ -153,6 +151,14 @@ func makeSearchQuery(searchStr, orderBy, order, query string, querySortFields []
return searchStr, query
}
// makeSearchString prepares a search string for use in both tsquery and ILIKE queries.
func makeSearchString(searchStr string) string {
if searchStr == "" {
return ""
}
return `%` + string(regexFullTextQuery.ReplaceAll([]byte(searchStr), []byte("&"))) + `%`
}
// strSliceContains checks if a string is present in the string slice.
func strSliceContains(str string, sl []string) bool {
for _, s := range sl {

View File

@@ -195,15 +195,25 @@ func (c *Core) UpdateList(id int, l models.List) (models.List, error) {
// DeleteList deletes a list.
func (c *Core) DeleteList(id int) error {
return c.DeleteLists([]int{id})
return c.DeleteLists([]int{id}, "", true, nil)
}
// DeleteLists deletes multiple lists.
func (c *Core) DeleteLists(ids []int) error {
if _, err := c.q.DeleteLists.Exec(pq.Array(ids)); err != nil {
func (c *Core) DeleteLists(ids []int, query string, getAll bool, permittedIDs []int) error {
var queryStr string
if len(ids) > 0 {
queryStr = ""
} else if query != "" {
queryStr = makeSearchString(query)
} else {
return echo.NewHTTPError(http.StatusBadRequest, c.i18n.T("globals.messages.invalidData"))
}
if _, err := c.q.DeleteLists.Exec(pq.Array(ids), queryStr, getAll, pq.Array(permittedIDs)); err != nil {
c.log.Printf("error deleting lists: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorDeleting", "name", "{globals.terms.list}", "error", pqErrMsg(err)))
c.i18n.Ts("globals.messages.errorDeleting", "name", "{globals.terms.lists}", "error", pqErrMsg(err)))
}
return nil
}

View File

@@ -88,6 +88,7 @@ type Queries struct {
UpdateCampaignArchive *sqlx.Stmt `query:"update-campaign-archive"`
RegisterCampaignView *sqlx.Stmt `query:"register-campaign-view"`
DeleteCampaign *sqlx.Stmt `query:"delete-campaign"`
DeleteCampaigns *sqlx.Stmt `query:"delete-campaigns"`
InsertMedia *sqlx.Stmt `query:"insert-media"`
GetMedia *sqlx.Stmt `query:"get-media"`

View File

@@ -431,6 +431,21 @@ UPDATE campaigns SET
-- name: delete-campaign
DELETE FROM campaigns WHERE id=$1;
-- name: delete-campaigns
DELETE FROM campaigns c
WHERE (
CASE
WHEN CARDINALITY($1::INT[]) > 0 THEN id = ANY($1)
ELSE $2 = '' OR TO_TSVECTOR(CONCAT(name, ' ', subject)) @@ TO_TSQUERY($2) OR CONCAT(c.name, ' ', c.subject) ILIKE $2
END
)
-- Get all campaigns or filter by permitted list IDs.
AND (
$3 OR EXISTS (
SELECT 1 FROM campaign_lists WHERE campaign_id = c.id AND list_id = ANY($4::INT[])
)
);
-- name: register-campaign-view
WITH view AS (
SELECT campaigns.id as campaign_id, subscribers.id AS subscriber_id FROM campaigns

View File

@@ -77,5 +77,13 @@ SELECT COUNT(*) FROM l, c;
UPDATE lists SET updated_at=NOW() WHERE id = ANY($1);
-- name: delete-lists
DELETE FROM lists WHERE id = ALL($1);
DELETE FROM lists
WHERE CASE
WHEN CARDINALITY($1::INT[]) > 0 THEN id = ANY($1)
ELSE ($2 = '' OR to_tsvector(name) @@ to_tsquery($2))
END
AND CASE
-- Optional list IDs based on user permission.
WHEN $3 = TRUE THEN TRUE ELSE id = ANY($4::INT[])
END;