diff --git a/cmd/campaigns.go b/cmd/campaigns.go index 106bfcf..55d9742 100644 --- a/cmd/campaigns.go +++ b/cmd/campaigns.go @@ -63,6 +63,8 @@ type campsWrap struct { var ( regexFromAddress = regexp.MustCompile(`(.+?)\s<(.+?)@(.+?)>`) regexFullTextQuery = regexp.MustCompile(`\s+`) + + campaignQuerySortFields = []string{"name", "status", "created_at", "updated_at"} ) // handleGetCampaigns handles retrieval of campaigns. @@ -75,11 +77,13 @@ func handleGetCampaigns(c echo.Context) error { id, _ = strconv.Atoi(c.Param("id")) status = c.QueryParams()["status"] query = strings.TrimSpace(c.FormValue("query")) + orderBy = c.FormValue("order_by") + order = c.FormValue("order") noBody, _ = strconv.ParseBool(c.QueryParam("no_body")) - single = false ) // Fetch one list. + single := false if id > 0 { single = true } @@ -88,8 +92,18 @@ func handleGetCampaigns(c echo.Context) error { string(regexFullTextQuery.ReplaceAll([]byte(query), []byte("&"))) + `%` } - err := app.queries.QueryCampaigns.Select(&out.Results, id, pq.StringArray(status), query, pg.Offset, pg.Limit) - if err != nil { + // Sort params. + if !strSliceContains(orderBy, campaignQuerySortFields) { + orderBy = "created_at" + } + if order != sortAsc && order != sortDesc { + order = sortDesc + } + + stmt := fmt.Sprintf(app.queries.QueryCampaigns, orderBy, order) + + // Unsafe to ignore scanning fields not present in models.Campaigns. + if err := db.Select(&out.Results, stmt, id, pq.StringArray(status), query, pg.Offset, pg.Limit); err != nil { app.log.Printf("error fetching campaigns: %v", err) return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Error fetching campaigns: %s", pqErrMsg(err))) diff --git a/cmd/handlers.go b/cmd/handlers.go index cbe335b..e46dd9e 100644 --- a/cmd/handlers.go +++ b/cmd/handlers.go @@ -14,6 +14,9 @@ import ( const ( // stdInputMaxLen is the maximum allowed length for a standard input field. stdInputMaxLen = 200 + + sortAsc = "asc" + sortDesc = "desc" ) type okResp struct { diff --git a/cmd/init.go b/cmd/init.go index 59590a6..3effbc3 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -197,6 +197,7 @@ func initQueries(sqlFile string, db *sqlx.DB, fs stuffbin.FileSystem, prepareQue if err := goyesqlx.ScanToStruct(&q, qMap, db.Unsafe()); err != nil { lo.Fatalf("error preparing SQL queries: %v", err) } + return qMap, &q } diff --git a/cmd/lists.go b/cmd/lists.go index 9169380..ad5639d 100644 --- a/cmd/lists.go +++ b/cmd/lists.go @@ -20,6 +20,10 @@ type listsWrap struct { Page int `json:"page"` } +var ( + listQuerySortFields = []string{"name", "type", "subscriber_count", "created_at", "updated_at"} +) + // handleGetLists handles retrieval of lists. func handleGetLists(c echo.Context) error { var ( @@ -27,6 +31,8 @@ func handleGetLists(c echo.Context) error { out listsWrap pg = getPagination(c.QueryParams(), 20, 50) + orderBy = c.FormValue("order_by") + order = c.FormValue("order") listID, _ = strconv.Atoi(c.Param("id")) single = false ) @@ -36,8 +42,15 @@ func handleGetLists(c echo.Context) error { single = true } - err := app.queries.GetLists.Select(&out.Results, listID, pg.Offset, pg.Limit) - if err != nil { + // Sort params. + if !strSliceContains(orderBy, listQuerySortFields) { + orderBy = "created_at" + } + if order != sortAsc && order != sortDesc { + order = sortAsc + } + + if err := db.Select(&out.Results, fmt.Sprintf(app.queries.GetLists, orderBy, order), listID, pg.Offset, pg.Limit); err != nil { app.log.Printf("error fetching lists: %v", err) return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Error fetching lists: %s", pqErrMsg(err))) diff --git a/cmd/queries.go b/cmd/queries.go index 5f49755..6e6e0c6 100644 --- a/cmd/queries.go +++ b/cmd/queries.go @@ -42,14 +42,14 @@ type Queries struct { UnsubscribeSubscribersFromListsByQuery string `query:"unsubscribe-subscribers-from-lists-by-query"` CreateList *sqlx.Stmt `query:"create-list"` - GetLists *sqlx.Stmt `query:"get-lists"` + GetLists string `query:"get-lists"` GetListsByOptin *sqlx.Stmt `query:"get-lists-by-optin"` UpdateList *sqlx.Stmt `query:"update-list"` UpdateListsDate *sqlx.Stmt `query:"update-lists-date"` DeleteLists *sqlx.Stmt `query:"delete-lists"` CreateCampaign *sqlx.Stmt `query:"create-campaign"` - QueryCampaigns *sqlx.Stmt `query:"query-campaigns"` + QueryCampaigns string `query:"query-campaigns"` GetCampaign *sqlx.Stmt `query:"get-campaign"` GetCampaignForPreview *sqlx.Stmt `query:"get-campaign-for-preview"` GetCampaignStats *sqlx.Stmt `query:"get-campaign-stats"` diff --git a/cmd/subscribers.go b/cmd/subscribers.go index 3ec6821..dbe461c 100644 --- a/cmd/subscribers.go +++ b/cmd/subscribers.go @@ -58,11 +58,15 @@ type subOptin struct { Lists []models.List } -var dummySubscriber = models.Subscriber{ - Email: "dummy@listmonk.app", - Name: "Dummy Subscriber", - UUID: dummyUUID, -} +var ( + dummySubscriber = models.Subscriber{ + Email: "dummy@listmonk.app", + Name: "Dummy Subscriber", + UUID: dummyUUID, + } + + subQuerySortFields = []string{"email", "name", "created_at", "updated_at"} +) // handleGetSubscriber handles the retrieval of a single subscriber by ID. func handleGetSubscriber(c echo.Context) error { @@ -89,8 +93,10 @@ func handleQuerySubscribers(c echo.Context) error { listID, _ = strconv.Atoi(c.FormValue("list_id")) // The "WHERE ?" bit. - query = sanitizeSQLExp(c.FormValue("query")) - out subsWrap + query = sanitizeSQLExp(c.FormValue("query")) + orderBy = c.FormValue("order_by") + order = c.FormValue("order") + out subsWrap ) listIDs := pq.Int64Array{} @@ -100,17 +106,21 @@ func handleQuerySubscribers(c echo.Context) error { listIDs = append(listIDs, int64(listID)) } - // There's an arbitrary query condition from the frontend. - var ( - cond = "" - ordBy = "updated_at" - ord = "DESC" - ) + // There's an arbitrary query condition. + cond := "" if query != "" { cond = " AND " + query } - stmt := fmt.Sprintf(app.queries.QuerySubscribers, cond, ordBy, ord) + // Sort params. + if !strSliceContains(orderBy, subQuerySortFields) { + orderBy = "updated_at" + } + if order != sortAsc && order != sortDesc { + order = sortAsc + } + + stmt := fmt.Sprintf(app.queries.QuerySubscribers, cond, orderBy, order) // Create a readonly transaction to prevent mutations. tx, err := app.db.BeginTxx(context.Background(), &sql.TxOptions{ReadOnly: true}) diff --git a/cmd/utils.go b/cmd/utils.go index 3113593..2f8c36b 100644 --- a/cmd/utils.go +++ b/cmd/utils.go @@ -132,3 +132,14 @@ func generateRandomString(n int) (string, error) { func strHasLen(str string, min, max int) bool { return len(str) >= min && len(str) <= max } + +// strSliceContains checks if a string is present in the string slice. +func strSliceContains(str string, sl []string) bool { + for _, s := range sl { + if s == str { + return true + } + } + + return false +} diff --git a/frontend/src/components/LogView.vue b/frontend/src/components/LogView.vue new file mode 100644 index 0000000..c1f3f08 --- /dev/null +++ b/frontend/src/components/LogView.vue @@ -0,0 +1,37 @@ + + + + diff --git a/frontend/src/views/Campaigns.vue b/frontend/src/views/Campaigns.vue index 4f89663..cfb7888 100644 --- a/frontend/src/views/Campaigns.vue +++ b/frontend/src/views/Campaigns.vue @@ -26,10 +26,10 @@ :row-class="highlightedRow" paginated backend-pagination pagination-position="both" @page-change="onPageChange" :current-page="queryParams.page" :per-page="campaigns.perPage" :total="campaigns.total" - hoverable> + hoverable backend-sorting @sort="onSort">