From 8044cb975ad151e314b97631246c7855d541ee05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?William=20Bouzour=C3=A8ne?= Date: Wed, 26 Mar 2025 18:13:00 +0100 Subject: [PATCH] Advanced search on members --- controllers/members.go | 125 ++------------- helpers/database/people.go | 320 ++++++++++++++++++++++++++++++++++--- static/search.js | 118 +++++++++----- views/people.html | 9 +- 4 files changed, 396 insertions(+), 176 deletions(-) diff --git a/controllers/members.go b/controllers/members.go index 6fa3aaa..f39d690 100644 --- a/controllers/members.go +++ b/controllers/members.go @@ -30,6 +30,8 @@ type PersonValidation struct { } func Members(c *fiber.Ctx) error { + log := helpers.GetLogger() + userid, err := helpers.GetSessionUserId(c) if err != nil { return err @@ -61,133 +63,30 @@ func Members(c *fiber.Ctx) error { return err } - /*sqlFilterSections := allowedSections - sqlFilterAppend := "IS NULL" - if filterArchive == "1" { - sqlFilterAppend = "IS NOT NULL" - sqlFilterSections = allowedSectionsArchived - } - - var filterSectionUint uint - if len(filterSection) > 0 { - filterSectionUint64, err := strconv.ParseUint(filterSection, 10, 0) - if err == nil { - for _, s := range sqlFilterSections { - if s == uint(filterSectionUint64) { - filterSectionUint = uint(filterSectionUint64) - } - } - } - } - - if filterSectionUint > 0 { - sqlFilterAppend = fmt.Sprintf( - "%s AND section_id = %d", - sqlFilterAppend, - filterSectionUint, - ) - } - - var sqlFilterSearch string - if len(filterSearch) > 0 { - sqlFilterSearch = fmt.Sprintf( - "%%%s%%", filterSearch, - ) - } - - var count int64 - if len(filterSearch) > 0 { - db.Model(&models.Person{}).Where( - fmt.Sprintf( - "is_member = ? AND section_id IN ? AND (first_name LIKE ? OR last_name LIKE ?) AND deleted_at %s", - sqlFilterAppend, - ), - true, allowedSections, sqlFilterSearch, sqlFilterSearch, - ).Count(&count) - } else { - db.Model(&models.Person{}).Where( - fmt.Sprintf( - "is_member = ? AND section_id IN ? AND deleted_at %s", - sqlFilterAppend, - ), - true, allowedSections, - ).Count(&count) - } - - var people []models.Person - if len(filterSearch) > 0 { - result := db.Unscoped().Offset( - pagination.Offset, - ).Limit( - pagination.PageSize, - ).Order( - "concat(last_name, first_name) collate nocase asc", - ).Preload( - "Section", - ).Find( - &people, - fmt.Sprintf( - "is_member = ? AND section_id IN ? AND (first_name LIKE ? OR last_name LIKE ?) AND deleted_at %s", - sqlFilterAppend, - ), - true, sqlFilterSections, sqlFilterSearch, sqlFilterSearch, - ) - - if result.Error != nil && !errors.Is(result.Error, gorm.ErrRecordNotFound) { - return err - } - } else { - result := db.Unscoped().Offset( - pagination.Offset, - ).Limit( - pagination.PageSize, - ).Order( - "concat(last_name, first_name) collate nocase asc", - ).Preload( - "Section", - ).Find( - &people, - fmt.Sprintf( - "is_member = ? AND section_id IN ? AND deleted_at %s", - sqlFilterAppend, - ), - true, sqlFilterSections, - ) - - if result.Error != nil && !errors.Is(result.Error, gorm.ErrRecordNotFound) { - return err - } - }*/ - - /*var sections []models.Section - db.Order( - "name collate nocase asc", - ).Find( - §ions, - "contains_members = ? AND id IN ?", - true, sqlFilterSections, - )*/ - searchJSON := c.Query("s") var params database.PeopleSearchParams if len(searchJSON) > 0 { err = json.Unmarshal([]byte(searchJSON), ¶ms) if err != nil { - return err + log.Warn(err) + searchJSON = "" } } - page, _ := strconv.Atoi(c.Query("p")) + params.PageNumber, _ = strconv.Atoi(c.Query("p")) + params.PageSize = 50 + params.PersonType = "members" - results, err := database.PeopleSearch(params, "members", 50, page) + var sections []models.Section + db.Order("name collate nocase asc").Find(§ions, "contains_members = ? AND id IN ?", true, allowedSections) + params.AllowedSections = allowedSections + + results, err := database.PeopleSearch(params) if err != nil { return err } - var sections []models.Section - db.Order("name collate nocase asc").Find(§ions, "contains_members = ?", true) - var fields []models.Field db.Order("position asc").Find(&fields, "person_type = ?", "member") diff --git a/helpers/database/people.go b/helpers/database/people.go index 0080452..779b97b 100644 --- a/helpers/database/people.go +++ b/helpers/database/people.go @@ -3,23 +3,30 @@ package database import ( "database/sql" "fmt" + "strings" "git.readonly.ch/bouzoure/pop-camarades/helpers" "git.readonly.ch/bouzoure/pop-camarades/models" ) type PeopleSearchParams struct { - Advanced bool `json:"advanced"` - Name string `json:"name"` - Section uint `json:"section"` - Active bool `json:"active"` - Archived bool `json:"archived"` - Email string `json:"email"` - Phone string `json:"phone"` - Address string `json:"address"` - PostalCode string `json:"postal_code"` - City string `json:"city"` - Fields map[uint]any `json:"fields"` + // JSON, provided by user + Advanced bool `json:"advanced"` + Name string `json:"name"` + Section uint `json:"section"` + Active bool `json:"active"` + Archive bool `json:"archive"` + Email string `json:"email"` + Phone string `json:"phone"` + Address string `json:"address"` + PostalCode string `json:"postal_code"` + City string `json:"city"` + Fields map[uint][]any `json:"fields"` + // Not JSON, defined in backend + PersonType string `json:"-"` + PageSize int `json:"-"` + PageNumber int `json:"-"` + AllowedSections []uint `json:"-"` } type PeopleSearchResults struct { @@ -28,7 +35,7 @@ type PeopleSearchResults struct { Pagination helpers.Pagination } -func PeopleSearch(params PeopleSearchParams, personType string, pageSize int, page int) (PeopleSearchResults, error) { +func PeopleSearch(params PeopleSearchParams) (PeopleSearchResults, error) { var results PeopleSearchResults db, err := helpers.GetDatabase() @@ -36,10 +43,9 @@ func PeopleSearch(params PeopleSearchParams, personType string, pageSize int, pa return results, nil } - var sqlQuery string - var sqlParams []any - sqlQuery = `--sql - SELECT people.ID, + // SQL qeury for results + sqlQuery := `--sql + SELECT people.id, people.is_member, people.is_contact, people.first_name, @@ -51,22 +57,292 @@ func PeopleSearch(params PeopleSearchParams, personType string, pageSize int, pa sections.name AS Section__name FROM people INNER JOIN sections - ON people.section_id = sections.id - WHERE is_member = @is_member - AND is_contact = @is_contact` + ON people.section_id = sections.id` - if personType == "members" { + // SQL query to count results + sqlQueryCount := `--sql + SELECT people.id + FROM people + ` + + // Create filters for both queries + var sqlParams []any + var sqlFilters string + + // Filter: member or contact + sqlFilters = `--sql + WHERE is_member = @is_member + AND is_contact = @is_contact + ` + if params.PersonType == "members" { sqlParams = append(sqlParams, sql.Named("is_member", true)) sqlParams = append(sqlParams, sql.Named("is_contact", false)) - } else if personType == "contacts" { + } else if params.PersonType == "contacts" { sqlParams = append(sqlParams, sql.Named("is_member", false)) sqlParams = append(sqlParams, sql.Named("is_contact", true)) } else { return results, fmt.Errorf("unkown person type") } - sqlResult := db.Raw(sqlQuery, sqlParams...).Scan(&results.Results) + // Filter: name -> first_name + last_name + if len(params.Name) > 0 { + nameFilter := `--sql + people.first_name LIKE @name + OR people.last_name LIKE @name + OR CONCAT(people.first_name, ' ', people.last_name) LIKE @name + OR CONCAT(people.last_name, ' ', people.first_name) LIKE @name + ` + sqlParams = append(sqlParams, sql.Named( + "name", + fmt.Sprintf("%%%s%%", params.Name), + )) + if strings.Contains(params.Name, " ") { + names := strings.Split(params.Name, " ") + for index, name := range names { + nameFilter = fmt.Sprintf(`--sql + %s + OR people.first_name LIKE @name_%d + OR people.last_name LIKE @name_%d + `, nameFilter, index, index) + + sqlParams = append(sqlParams, sql.Named( + fmt.Sprintf("name_%d", index), + fmt.Sprintf("%%%s%%", name), + )) + } + } + + sqlFilters = fmt.Sprintf(`--sql + %s + AND (%s) + `, sqlFilters, nameFilter) + } + + // Filter: section + if params.Section > 0 { + sqlFilters = fmt.Sprintf(`--sql + %s + AND people.section_id = @section + `, sqlFilters) + sqlParams = append(sqlParams, sql.Named("section", params.Section)) + } + + // Filter: active (only apply if archived is false) + if params.Active && !params.Archive { + sqlFilters = fmt.Sprintf(`--sql + %s + AND people.deleted_at IS NULL + `, sqlFilters) + } + + // Filter: archived (only apply if active is false) + if params.Archive && !params.Active { + sqlFilters = fmt.Sprintf(`--sql + %s + AND people.deleted_at IS NOT NULL + `, sqlFilters) + } + + // Filters: email + if len(params.Email) > 0 { + sqlFilters = fmt.Sprintf(`--sql + %s + AND people.email LIKE @email + `, sqlFilters) + sqlParams = append(sqlParams, sql.Named( + "email", + fmt.Sprintf("%%%s%%", params.Email), + )) + } + + // Filters: phone + if len(params.Phone) > 0 { + params.Phone = strings.ReplaceAll(params.Phone, " ", "") + params.Phone = strings.ReplaceAll(params.Phone, "-", "") + params.Phone = strings.ReplaceAll(params.Phone, ".", "") + params.Phone = strings.ReplaceAll(params.Phone, "/", "") + params.Phone = strings.ReplaceAll(params.Phone, "+", "") + + sqlFilters = fmt.Sprintf(`--sql + %s + AND ( + REPLACE( + REPLACE( + REPLACE( + REPLACE( + REPLACE(people.phone, ' ', '') + , '-', '') + , '.', '') + , '/', '') + , '+', '') LIKE @phone + OR REPLACE( + REPLACE( + REPLACE( + REPLACE( + REPLACE(people.mobile, ' ', '') + , '-', '') + , '.', '') + , '/', '') + , '+', '') LIKE @phone + ) + `, sqlFilters) + sqlParams = append(sqlParams, sql.Named( + "phone", + fmt.Sprintf("%%%s%%", params.Phone), + )) + } + + // Filters: address + if len(params.Address) > 0 { + sqlFilters = fmt.Sprintf(`--sql + %s + AND ( + people.address1 LIKE @address + OR people.address2 LIKE @address + ) + `, sqlFilters) + sqlParams = append(sqlParams, sql.Named( + "address", + fmt.Sprintf("%%%s%%", params.Address), + )) + } + + // Filters: postal code + if len(params.PostalCode) > 0 { + sqlFilters = fmt.Sprintf(`--sql + %s + AND people.postal_code = @postal_code + `, sqlFilters) + sqlParams = append(sqlParams, sql.Named( + "postal_code", + params.PostalCode, + )) + } + + // Filters: city + if len(params.City) > 0 { + sqlFilters = fmt.Sprintf(`--sql + %s + AND people.city LIKE @city + `, sqlFilters) + sqlParams = append(sqlParams, sql.Named( + "city", + fmt.Sprintf("%%%s%%", params.City), + )) + } + + // Security filter: only show results in allowed secitons + sqlFilters = fmt.Sprintf(`--sql + %s + AND people.section_id IN @allowed_sections + `, sqlFilters) + sqlParams = append(sqlParams, sql.Named("allowed_sections", params.AllowedSections)) + + // Filter: optional fields + var sqlFieldJoins string + for fieldID, values := range params.Fields { + var field models.Field + db.First(&field, "id = ?", fieldID) + + // Skip if field ID not found in DB + if field.ID <= 0 { + continue + } + + sqlFieldJoins = fmt.Sprintf(`--sql + %s + LEFT JOIN field_values + AS field_%d + ON people.id = field_%d.person_id + AND field_%d.field_id = @field_%d_id + `, sqlFieldJoins, field.ID, field.ID, field.ID, field.ID) + + sqlParams = append(sqlParams, sql.Named( + fmt.Sprintf("field_%d_id", field.ID), + field.ID, + )) + + var fieldFilter string + for index, value := range values { + var filter string + if fieldFilter != "" { + filter = "OR" + } + + switch v := value.(type) { + default: + fmt.Println(v) + continue + case string: + filter = fmt.Sprintf(`--sql + %s + field_%d.value_string LIKE @field_%d_%d + OR field_%d.value_date LIKE @field_%d_%d + `, filter, field.ID, field.ID, index, field.ID, field.ID, index) + sqlParams = append(sqlParams, sql.Named( + fmt.Sprintf("field_%d_%d", field.ID, index), + fmt.Sprintf("%%%s%%", v), + )) + case float64: + filter = fmt.Sprintf(`--sql + %s + field_%d.value_int = @field_%d_%d + OR field_%d.list_item_id = @field_%d_%d + `, filter, field.ID, field.ID, index, field.ID, field.ID, index) + sqlParams = append(sqlParams, sql.Named( + fmt.Sprintf("field_%d_%d", field.ID, index), + v, + )) + } + + fieldFilter = fmt.Sprintf("%s %s", fieldFilter, filter) + } + + if fieldFilter != "" { + sqlFilters = fmt.Sprintf(`--sql + %s + AND (%s) + `, sqlFilters, fieldFilter) + } + } + + // Build and run count query + sqlQueryCount = fmt.Sprintf(`--sql + %s + %s + %s + GROUP BY people.id + `, sqlQueryCount, sqlFieldJoins, sqlFilters) + + var count []uint + sqlCountResult := db.Raw(sqlQueryCount, sqlParams...).Scan(&count) + if sqlCountResult.Error != nil { + return results, sqlCountResult.Error + } + + // Create pagination with count + results.Pagination = helpers.Paginate( + params.PageSize, + len(count), + params.PageNumber, + ) + + // Build and run paginated result query + sqlQuery = fmt.Sprintf(`--sql + %s + %s + %s + GROUP BY people.id + ORDER BY CONCAT(people.last_name, people.first_name) COLLATE NOCASE ASC + LIMIT @pagination_limit + OFFSET @pagination_offset + `, sqlQuery, sqlFieldJoins, sqlFilters) + + sqlParams = append(sqlParams, sql.Named("pagination_limit", results.Pagination.PageSize)) + sqlParams = append(sqlParams, sql.Named("pagination_offset", results.Pagination.Offset)) + + sqlResult := db.Raw(sqlQuery, sqlParams...).Scan(&results.Results) if sqlResult.Error != nil { return results, sqlResult.Error } diff --git a/static/search.js b/static/search.js index 5dbe1ff..34f4ecb 100644 --- a/static/search.js +++ b/static/search.js @@ -17,40 +17,27 @@ $(document).ready(function() { } }); - var json = $("#search-json").val(); - if (json.length > 0) { - var searchData = JSON.parse(json); - for (const [key, value] of Object.entries(searchData)) { - if (key === "advanced") { - if (value) $("#advanced").trigger("click"); - continue; - } + $("#reset-search").on("click", function() { + $("#search-json").val(""); + $("#search-page").val("1"); + $("#search-form").submit(); + }); - if (key === "fields") { - // TODO: gérer les champs suppl. - continue - } + $("#search-container").on("change", ".field-select", function() { + var lastField = $(this).attr("data-last-field"); + var isInitial = $(this).attr("data-is-initial"); + var initialValue = $(this).attr("data-initial-value"); - if (typeof value === "boolean") { - $("[data-search-field=" + key + "]").prop("checked", value); - } else { - $("[data-search-field=" + key + "]").val(value); - } - } - } - - createFieldSelection(); - $("body").on("change", ".search-fields-count .field-select", function() { - var lastfield = $(this).attr("data-last-field"); var field = $(this).val(); if (field == "") { $(this).parents(".row").remove(); return; } - if (lastfield.length === 0) { + if ((lastField === undefined || lastField.length === 0) && (isInitial === undefined || isInitial !== "yes")) { createFieldSelection(); } + $(this).attr("data-last-field", field); $(this).parents(".row").find(".end-col").find("input").remove(); @@ -69,7 +56,8 @@ $(document).ready(function() { if (data.FieldType === "list") { var select = $("", { class: "form-control", type: "number", - "data-optional-field": field + "data-optional-field": field, + "data-search-type": "int" })); } else if (data.FieldType === "date") { $(elem).parents(".row").find(".end-col").append($("", { @@ -98,21 +87,62 @@ $(document).ready(function() { "data-optional-field": field })); } + + if (initialValue !== undefined && initialValue.length > 0) { + $(elem).parents(".row").find("[data-optional-field]").val(initialValue); + $(elem).attr("data-initial-value", null); + } }); }); + + $("#search-container").on("keyup", "input[type=text]", function(e) { + if (e.keyCode === 13) { + search(); + } + }); + + var json = $("#search-json").val(); + if (json.length > 0) { + var searchData = JSON.parse(json); + + for (const [key, value] of Object.entries(searchData)) { + if (key === "advanced") { + if (value) $("#advanced").trigger("click"); + continue; + } + + if (key === "fields") { + for (const [field_id, field_values] of Object.entries(value)) { + for (const field_value of field_values) { + createFieldSelection(field_id, field_value); + } + } + continue + } + + if (typeof value === "boolean") { + $("[data-search-field=" + key + "]").prop("checked", value); + } else { + $("[data-search-field=" + key + "]").val(value); + } + } + } + + createFieldSelection(); }); -function createFieldSelection() { - const uuid = crypto.randomUUID(); - +function createFieldSelection(field, value) { var elem = $("#search-fields-model").clone().appendTo("#search-fields"); - $(elem).attr("id", "search-field-" + uuid); - //$(elem).find(".start-col").find("label").attr("for", "search-field-field-" + uuid); - //$(elem).find(".start-col").find("select").attr("id", "search-field-field-" + uuid); - //$(elem).find(".end-col").find("label").attr("for", "search-field-value-" + uuid); - //$(elem).find(".end-col").find("input").attr("id", "search-field-value-" + uuid); + $(elem).attr("id", null); $(elem).removeClass("d-none"); $(elem).addClass("search-fields-count"); + + if (field !== undefined && field.length > 0) { + $(elem).find(".field-select").attr("data-initial-value", value); + $(elem).find(".field-select").attr("data-is-initial", "yes"); + $(elem).find(".field-select").val(field); + $(elem).find(".field-select").trigger("change"); + } } function search() { @@ -130,7 +160,10 @@ function search() { var index = $(this).data("search-field"); var value = $(this).val(); - if ($(this).attr("type") == "checkbox") { + if ($(this).data("search-type") === "int") { + value = parseInt(value); + if (isNaN(value)) return; + } else if ($(this).attr("type") === "checkbox") { value = $(this).prop("checked"); } @@ -142,12 +175,21 @@ function search() { var index = $(this).attr("data-optional-field"); var value = $(this).val(); - fields[index] = value; + if ($(this).data("search-type") === "int") { + value = parseInt(value); + if (isNaN(value)) return; + } + + if (fields[index] !== undefined) { + fields[index].push(value); + } else { + fields[index] = [value]; + } }); searchData["fields"] = fields; var json = JSON.stringify(searchData); $("#search-json").val(json); - console.log(searchData); - //$("#search-form").submit(); + $("#search-page").val("1"); + $("#search-form").submit(); } \ No newline at end of file diff --git a/views/people.html b/views/people.html index f6323af..1eeacd5 100644 --- a/views/people.html +++ b/views/people.html @@ -44,7 +44,7 @@ {% endif %} -
+
@@ -55,7 +55,7 @@
- {% for Section in Sections %}
- +