Advanced search on members

This commit is contained in:
William Bouzourène 2025-03-26 18:13:00 +01:00
parent 8e002415d0
commit d8662a32d1
4 changed files with 396 additions and 176 deletions

View file

@ -30,6 +30,8 @@ type PersonValidation struct {
} }
func Members(c *fiber.Ctx) error { func Members(c *fiber.Ctx) error {
log := helpers.GetLogger()
userid, err := helpers.GetSessionUserId(c) userid, err := helpers.GetSessionUserId(c)
if err != nil { if err != nil {
return err return err
@ -61,133 +63,30 @@ func Members(c *fiber.Ctx) error {
return err 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(
&sections,
"contains_members = ? AND id IN ?",
true, sqlFilterSections,
)*/
searchJSON := c.Query("s") searchJSON := c.Query("s")
var params database.PeopleSearchParams var params database.PeopleSearchParams
if len(searchJSON) > 0 { if len(searchJSON) > 0 {
err = json.Unmarshal([]byte(searchJSON), &params) err = json.Unmarshal([]byte(searchJSON), &params)
if err != nil { 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(&sections, "contains_members = ? AND id IN ?", true, allowedSections)
params.AllowedSections = allowedSections
results, err := database.PeopleSearch(params)
if err != nil { if err != nil {
return err return err
} }
var sections []models.Section
db.Order("name collate nocase asc").Find(&sections, "contains_members = ?", true)
var fields []models.Field var fields []models.Field
db.Order("position asc").Find(&fields, "person_type = ?", "member") db.Order("position asc").Find(&fields, "person_type = ?", "member")

View file

@ -3,23 +3,30 @@ package database
import ( import (
"database/sql" "database/sql"
"fmt" "fmt"
"strings"
"git.readonly.ch/bouzoure/pop-camarades/helpers" "git.readonly.ch/bouzoure/pop-camarades/helpers"
"git.readonly.ch/bouzoure/pop-camarades/models" "git.readonly.ch/bouzoure/pop-camarades/models"
) )
type PeopleSearchParams struct { type PeopleSearchParams struct {
// JSON, provided by user
Advanced bool `json:"advanced"` Advanced bool `json:"advanced"`
Name string `json:"name"` Name string `json:"name"`
Section uint `json:"section"` Section uint `json:"section"`
Active bool `json:"active"` Active bool `json:"active"`
Archived bool `json:"archived"` Archive bool `json:"archive"`
Email string `json:"email"` Email string `json:"email"`
Phone string `json:"phone"` Phone string `json:"phone"`
Address string `json:"address"` Address string `json:"address"`
PostalCode string `json:"postal_code"` PostalCode string `json:"postal_code"`
City string `json:"city"` City string `json:"city"`
Fields map[uint]any `json:"fields"` 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 { type PeopleSearchResults struct {
@ -28,7 +35,7 @@ type PeopleSearchResults struct {
Pagination helpers.Pagination Pagination helpers.Pagination
} }
func PeopleSearch(params PeopleSearchParams, personType string, pageSize int, page int) (PeopleSearchResults, error) { func PeopleSearch(params PeopleSearchParams) (PeopleSearchResults, error) {
var results PeopleSearchResults var results PeopleSearchResults
db, err := helpers.GetDatabase() db, err := helpers.GetDatabase()
@ -36,10 +43,9 @@ func PeopleSearch(params PeopleSearchParams, personType string, pageSize int, pa
return results, nil return results, nil
} }
var sqlQuery string // SQL qeury for results
var sqlParams []any sqlQuery := `--sql
sqlQuery = `--sql SELECT people.id,
SELECT people.ID,
people.is_member, people.is_member,
people.is_contact, people.is_contact,
people.first_name, people.first_name,
@ -51,22 +57,292 @@ func PeopleSearch(params PeopleSearchParams, personType string, pageSize int, pa
sections.name AS Section__name sections.name AS Section__name
FROM people FROM people
INNER JOIN sections INNER JOIN sections
ON people.section_id = sections.id ON people.section_id = sections.id`
WHERE is_member = @is_member
AND is_contact = @is_contact`
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_member", true))
sqlParams = append(sqlParams, sql.Named("is_contact", false)) 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_member", false))
sqlParams = append(sqlParams, sql.Named("is_contact", true)) sqlParams = append(sqlParams, sql.Named("is_contact", true))
} else { } else {
return results, fmt.Errorf("unkown person type") 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 { if sqlResult.Error != nil {
return results, sqlResult.Error return results, sqlResult.Error
} }

View file

@ -17,40 +17,27 @@ $(document).ready(function() {
} }
}); });
var json = $("#search-json").val(); $("#reset-search").on("click", function() {
if (json.length > 0) { $("#search-json").val("");
var searchData = JSON.parse(json); $("#search-page").val("1");
for (const [key, value] of Object.entries(searchData)) { $("#search-form").submit();
if (key === "advanced") { });
if (value) $("#advanced").trigger("click");
continue;
}
if (key === "fields") { $("#search-container").on("change", ".field-select", function() {
// TODO: gérer les champs suppl. var lastField = $(this).attr("data-last-field");
continue 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(); var field = $(this).val();
if (field == "") { if (field == "") {
$(this).parents(".row").remove(); $(this).parents(".row").remove();
return; return;
} }
if (lastfield.length === 0) { if ((lastField === undefined || lastField.length === 0) && (isInitial === undefined || isInitial !== "yes")) {
createFieldSelection(); createFieldSelection();
} }
$(this).attr("data-last-field", field); $(this).attr("data-last-field", field);
$(this).parents(".row").find(".end-col").find("input").remove(); $(this).parents(".row").find(".end-col").find("input").remove();
@ -69,7 +56,8 @@ $(document).ready(function() {
if (data.FieldType === "list") { if (data.FieldType === "list") {
var select = $("<select>", { var select = $("<select>", {
class: "form-select", class: "form-select",
"data-optional-field": field "data-optional-field": field,
"data-search-type": "int"
}); });
$(elem).parents(".row").find(".end-col").append(select); $(elem).parents(".row").find(".end-col").append(select);
@ -83,7 +71,8 @@ $(document).ready(function() {
$(elem).parents(".row").find(".end-col").append($("<input>", { $(elem).parents(".row").find(".end-col").append($("<input>", {
class: "form-control", class: "form-control",
type: "number", type: "number",
"data-optional-field": field "data-optional-field": field,
"data-search-type": "int"
})); }));
} else if (data.FieldType === "date") { } else if (data.FieldType === "date") {
$(elem).parents(".row").find(".end-col").append($("<input>", { $(elem).parents(".row").find(".end-col").append($("<input>", {
@ -98,21 +87,62 @@ $(document).ready(function() {
"data-optional-field": field "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);
}
}); });
}); });
function createFieldSelection() { $("#search-container").on("keyup", "input[type=text]", function(e) {
const uuid = crypto.randomUUID(); 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(field, value) {
var elem = $("#search-fields-model").clone().appendTo("#search-fields"); var elem = $("#search-fields-model").clone().appendTo("#search-fields");
$(elem).attr("id", "search-field-" + uuid); $(elem).attr("id", null);
//$(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).removeClass("d-none"); $(elem).removeClass("d-none");
$(elem).addClass("search-fields-count"); $(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() { function search() {
@ -130,7 +160,10 @@ function search() {
var index = $(this).data("search-field"); var index = $(this).data("search-field");
var value = $(this).val(); 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"); value = $(this).prop("checked");
} }
@ -142,12 +175,21 @@ function search() {
var index = $(this).attr("data-optional-field"); var index = $(this).attr("data-optional-field");
var value = $(this).val(); 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; searchData["fields"] = fields;
var json = JSON.stringify(searchData); var json = JSON.stringify(searchData);
$("#search-json").val(json); $("#search-json").val(json);
console.log(searchData); $("#search-page").val("1");
//$("#search-form").submit(); $("#search-form").submit();
} }

View file

@ -44,7 +44,7 @@
{% endif %} {% endif %}
</div> </div>
<div class="h-100 p-4 mb-3 bg-body-tertiary border rounded-3"> <div id="search-container" class="h-100 p-4 mb-3 bg-body-tertiary border rounded-3">
<div class="row"> <div class="row">
<div class="col-lg-6 mb-3"> <div class="col-lg-6 mb-3">
@ -55,7 +55,7 @@
<div class="col-sm-6 col-lg-3 mb-3"> <div class="col-sm-6 col-lg-3 mb-3">
<label for="section" class="form-label">Section</label> <label for="section" class="form-label">Section</label>
<select class="form-select" id="section" data-search-field="section" data-search-advanced="false" name="section"> <select class="form-select" id="section" data-search-field="section" data-search-type="int" data-search-advanced="false" name="section">
<option value=""></option> <option value=""></option>
{% for Section in Sections %} {% for Section in Sections %}
<option value="{{ Section.ID }}"> <option value="{{ Section.ID }}">
@ -148,6 +148,9 @@
<button class="btn btn-outline-primary" id="advanced" data-state="false" type="button"> <button class="btn btn-outline-primary" id="advanced" data-state="false" type="button">
<i class="bi-arrow-down me-1"></i> Avancé <i class="bi-arrow-down me-1"></i> Avancé
</button> </button>
<button class="btn btn-outline-primary" id="reset-search" type="button">
<i class="bi-arrow-clockwise me-1"></i> Réinitialiser
</button>
</div> </div>
<div class="col-6 text-end"> <div class="col-6 text-end">
<button class="btn btn-outline-success" id="search" type="button"> <button class="btn btn-outline-success" id="search" type="button">
@ -157,7 +160,7 @@
</div> </div>
<form id="search-form" method="get" class="d-none"> <form id="search-form" method="get" class="d-none">
<input type="hidden" name="p" value="{{ Pagination.CurrentPage }}"> <input type="hidden" id="search-page" name="p" value="{{ Pagination.CurrentPage }}">
<input type="hidden" id="search-json" name="s" value="{{ SearchJSON }}"> <input type="hidden" id="search-json" name="s" value="{{ SearchJSON }}">
</form> </form>
</div> </div>