614 lines
18 KiB
Go
614 lines
18 KiB
Go
package handlers
|
|
|
|
import (
|
|
"fmt"
|
|
"log"
|
|
"net/http"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/patel-mann/poll-system/app/internal/models"
|
|
"github.com/patel-mann/poll-system/app/internal/utils"
|
|
)
|
|
|
|
// SmartFilterQuery represents a parsed smart filter query
|
|
type SmartFilterQuery struct {
|
|
Tables []string
|
|
Conditions []FilterCondition
|
|
Groupings []string
|
|
Aggregations []string
|
|
Sorting []string
|
|
Limit int
|
|
}
|
|
|
|
type FilterCondition struct {
|
|
Table string
|
|
Column string
|
|
Operator string
|
|
Value interface{}
|
|
LogicalOp string // AND, OR
|
|
}
|
|
|
|
// SmartFilterResult represents the result of a smart filter query
|
|
type SmartFilterResult struct {
|
|
Columns []string
|
|
Rows [][]interface{}
|
|
Query string
|
|
Count int
|
|
Error string
|
|
}
|
|
|
|
// SmartFilterHandler handles intelligent filtering with natural language parsing
|
|
func SmartFilterHandler(w http.ResponseWriter, r *http.Request) {
|
|
username, _ := models.GetCurrentUserName(r)
|
|
role := r.Context().Value("user_role").(int)
|
|
|
|
if role != 1 {
|
|
http.Error(w, "Unauthorized", http.StatusForbidden)
|
|
return
|
|
}
|
|
|
|
smartQuery := r.URL.Query().Get("smart_query")
|
|
var result SmartFilterResult
|
|
|
|
if smartQuery != "" {
|
|
result = executeSmartFilter(smartQuery)
|
|
}
|
|
|
|
adminnav := role == 1
|
|
volunteernav := role != 1
|
|
|
|
utils.Render(w, "smart_reports.html", map[string]interface{}{
|
|
"Title": "Smart Reports & Analytics",
|
|
"IsAuthenticated": true,
|
|
"ShowAdminNav": adminnav,
|
|
"ShowVolunteerNav": volunteernav,
|
|
"UserName": username,
|
|
"ActiveSection": "reports",
|
|
"SmartQuery": smartQuery,
|
|
"Result": result,
|
|
"QueryExamples": getQueryExamples(),
|
|
})
|
|
}
|
|
|
|
// executeSmartFilter parses and executes a smart filter query
|
|
func executeSmartFilter(query string) SmartFilterResult {
|
|
parsed := parseSmartQuery(query)
|
|
if parsed == nil {
|
|
return SmartFilterResult{Error: "Could not parse query"}
|
|
}
|
|
|
|
sqlQuery, err := buildSQLFromParsed(parsed)
|
|
if err != nil {
|
|
return SmartFilterResult{Error: err.Error()}
|
|
}
|
|
|
|
return executeSQLQuery(sqlQuery)
|
|
}
|
|
|
|
// parseSmartQuery parses natural language into structured query
|
|
func parseSmartQuery(query string) *SmartFilterQuery {
|
|
query = strings.ToLower(strings.TrimSpace(query))
|
|
|
|
// Initialize the parsed query
|
|
parsed := &SmartFilterQuery{
|
|
Tables: []string{},
|
|
Conditions: []FilterCondition{},
|
|
Limit: 100, // Default limit
|
|
}
|
|
|
|
// Define entity mappings
|
|
entityMappings := map[string]string{
|
|
"volunteer": "users",
|
|
"volunteers": "users",
|
|
"user": "users",
|
|
"users": "users",
|
|
"admin": "users",
|
|
"admins": "users",
|
|
"poll": "poll",
|
|
"polls": "poll",
|
|
"address": "address_database",
|
|
"addresses": "address_database",
|
|
"appointment": "appointment",
|
|
"appointments": "appointment",
|
|
"team": "team",
|
|
"teams": "team",
|
|
}
|
|
|
|
// Define column mappings for each table
|
|
columnMappings := map[string]map[string]string{
|
|
"users": {
|
|
"name": "first_name || ' ' || last_name",
|
|
"email": "email",
|
|
"phone": "phone",
|
|
"role": "role_id",
|
|
"created": "created_at",
|
|
"updated": "updated_at",
|
|
},
|
|
"poll": {
|
|
"title": "poll_title",
|
|
"description": "poll_description",
|
|
"active": "is_active",
|
|
"donated": "amount_donated",
|
|
"donation": "amount_donated",
|
|
"money": "amount_donated",
|
|
"amount": "amount_donated",
|
|
"created": "created_at",
|
|
"updated": "updated_at",
|
|
},
|
|
"address_database": {
|
|
"address": "address",
|
|
"street": "street_name",
|
|
"house": "house_number",
|
|
"visited": "visited_validated",
|
|
"latitude": "latitude",
|
|
"longitude": "longitude",
|
|
"created": "created_at",
|
|
},
|
|
"appointment": {
|
|
"date": "appointment_date",
|
|
"time": "appointment_time",
|
|
"created": "created_at",
|
|
},
|
|
"team": {
|
|
"lead": "team_lead_id",
|
|
"volunteer": "volunteer_id",
|
|
"created": "created_at",
|
|
},
|
|
}
|
|
|
|
_ = columnMappings
|
|
|
|
// Extract entities from query
|
|
for keyword, table := range entityMappings {
|
|
if strings.Contains(query, keyword) {
|
|
if !contains(parsed.Tables, table) {
|
|
parsed.Tables = append(parsed.Tables, table)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Parse conditions using regex patterns
|
|
conditionPatterns := []struct {
|
|
pattern string
|
|
handler func(matches []string, parsed *SmartFilterQuery)
|
|
}{
|
|
// "volunteers who went to address X"
|
|
{`(volunteer|user)s?\s+(?:who\s+)?(?:went\s+to|visited)\s+(?:address\s+)?(.+)`, func(matches []string, parsed *SmartFilterQuery) {
|
|
addTablesIfNeeded(parsed, []string{"users", "appointment", "address_database"})
|
|
parsed.Conditions = append(parsed.Conditions, FilterCondition{
|
|
Table: "address_database", Column: "address", Operator: "ILIKE", Value: "%" + matches[2] + "%", LogicalOp: "AND",
|
|
})
|
|
}},
|
|
|
|
// "poll responses for address X"
|
|
{`poll\s+(?:response|responses|data)\s+(?:for|of|at)\s+(?:address\s+)?(.+)`, func(matches []string, parsed *SmartFilterQuery) {
|
|
addTablesIfNeeded(parsed, []string{"poll", "address_database"})
|
|
parsed.Conditions = append(parsed.Conditions, FilterCondition{
|
|
Table: "address_database", Column: "address", Operator: "ILIKE", Value: "%" + matches[1] + "%", LogicalOp: "AND",
|
|
})
|
|
}},
|
|
|
|
// "donations by volunteer X"
|
|
{`(?:donation|donations|money|amount)\s+(?:by|from)\s+(?:volunteer\s+)?(.+)`, func(matches []string, parsed *SmartFilterQuery) {
|
|
addTablesIfNeeded(parsed, []string{"poll", "users"})
|
|
parsed.Conditions = append(parsed.Conditions, FilterCondition{
|
|
Table: "users", Column: "first_name || ' ' || last_name", Operator: "ILIKE", Value: "%" + matches[1] + "%", LogicalOp: "AND",
|
|
})
|
|
parsed.Aggregations = append(parsed.Aggregations, "SUM(poll.amount_donated) as total_donated")
|
|
}},
|
|
|
|
// "team with most appointments"
|
|
{`team\s+(?:with\s+)?(?:most|highest)\s+appointment`, func(matches []string, parsed *SmartFilterQuery) {
|
|
addTablesIfNeeded(parsed, []string{"team", "appointment"})
|
|
parsed.Aggregations = append(parsed.Aggregations, "COUNT(appointment.sched_id) as appointment_count")
|
|
parsed.Groupings = append(parsed.Groupings, "team.team_id")
|
|
parsed.Sorting = append(parsed.Sorting, "appointment_count DESC")
|
|
}},
|
|
|
|
// "people in team X"
|
|
{`(?:people|members|users)\s+in\s+team\s+(\d+)`, func(matches []string, parsed *SmartFilterQuery) {
|
|
addTablesIfNeeded(parsed, []string{"team", "users"})
|
|
teamID, _ := strconv.Atoi(matches[1])
|
|
parsed.Conditions = append(parsed.Conditions, FilterCondition{
|
|
Table: "team", Column: "team_id", Operator: "=", Value: teamID, LogicalOp: "AND",
|
|
})
|
|
}},
|
|
|
|
// "money made by team X"
|
|
{`(?:money|donation|amount)\s+(?:made|earned)\s+by\s+team\s+(\d+)`, func(matches []string, parsed *SmartFilterQuery) {
|
|
addTablesIfNeeded(parsed, []string{"team", "users", "poll"})
|
|
teamID, _ := strconv.Atoi(matches[1])
|
|
parsed.Conditions = append(parsed.Conditions, FilterCondition{
|
|
Table: "team", Column: "team_id", Operator: "=", Value: teamID, LogicalOp: "AND",
|
|
})
|
|
parsed.Aggregations = append(parsed.Aggregations, "SUM(poll.amount_donated) as team_total")
|
|
}},
|
|
|
|
// "visited addresses"
|
|
{`visited\s+address`, func(matches []string, parsed *SmartFilterQuery) {
|
|
addTablesIfNeeded(parsed, []string{"address_database"})
|
|
parsed.Conditions = append(parsed.Conditions, FilterCondition{
|
|
Table: "address_database", Column: "visited_validated", Operator: "=", Value: true, LogicalOp: "AND",
|
|
})
|
|
}},
|
|
|
|
// "active polls"
|
|
{`active\s+poll`, func(matches []string, parsed *SmartFilterQuery) {
|
|
addTablesIfNeeded(parsed, []string{"poll"})
|
|
parsed.Conditions = append(parsed.Conditions, FilterCondition{
|
|
Table: "poll", Column: "is_active", Operator: "=", Value: true, LogicalOp: "AND",
|
|
})
|
|
}},
|
|
|
|
// Date filters
|
|
{`(?:from|after)\s+(\d{4}-\d{2}-\d{2})`, func(matches []string, parsed *SmartFilterQuery) {
|
|
for _, table := range parsed.Tables {
|
|
parsed.Conditions = append(parsed.Conditions, FilterCondition{
|
|
Table: table, Column: "created_at", Operator: ">=", Value: matches[1], LogicalOp: "AND",
|
|
})
|
|
}
|
|
}},
|
|
|
|
{`(?:to|before|until)\s+(\d{4}-\d{2}-\d{2})`, func(matches []string, parsed *SmartFilterQuery) {
|
|
for _, table := range parsed.Tables {
|
|
parsed.Conditions = append(parsed.Conditions, FilterCondition{
|
|
Table: table, Column: "created_at", Operator: "<=", Value: matches[1] + " 23:59:59", LogicalOp: "AND",
|
|
})
|
|
}
|
|
}},
|
|
}
|
|
|
|
// Apply pattern matching
|
|
for _, pattern := range conditionPatterns {
|
|
re := regexp.MustCompile(pattern.pattern)
|
|
if matches := re.FindStringSubmatch(query); matches != nil {
|
|
pattern.handler(matches, parsed)
|
|
}
|
|
}
|
|
|
|
// If no tables were identified, return nil
|
|
if len(parsed.Tables) == 0 {
|
|
return nil
|
|
}
|
|
|
|
return parsed
|
|
}
|
|
|
|
// buildSQLFromParsed converts parsed query into SQL
|
|
func buildSQLFromParsed(parsed *SmartFilterQuery) (string, error) {
|
|
var selectCols []string
|
|
var fromClause []string
|
|
var joinClauses []string
|
|
var whereConditions []string
|
|
var groupByClause string
|
|
var orderByClause string
|
|
|
|
// Define table aliases
|
|
aliases := map[string]string{
|
|
"users": "u",
|
|
"poll": "p",
|
|
"address_database": "a",
|
|
"appointment": "ap",
|
|
"team": "t",
|
|
}
|
|
|
|
// Define default columns for each table
|
|
defaultColumns := map[string][]string{
|
|
"users": {"u.user_id", "u.first_name", "u.last_name", "u.email", "u.role_id", "u.created_at"},
|
|
"poll": {"p.poll_id", "p.poll_title", "p.poll_description", "p.is_active", "p.amount_donated", "p.created_at"},
|
|
"address_database": {"a.address_id", "a.address", "a.street_name", "a.visited_validated", "a.created_at"},
|
|
"appointment": {"ap.sched_id", "ap.appointment_date", "ap.appointment_time", "ap.created_at"},
|
|
"team": {"t.team_id", "t.team_lead_id", "t.volunteer_id", "t.created_at"},
|
|
}
|
|
|
|
// Build SELECT clause
|
|
if len(parsed.Aggregations) > 0 {
|
|
selectCols = parsed.Aggregations
|
|
// Add grouping columns
|
|
for _, table := range parsed.Tables {
|
|
alias := aliases[table]
|
|
if table == "users" {
|
|
selectCols = append(selectCols, alias+".first_name || ' ' || "+alias+".last_name as user_name")
|
|
} else if table == "team" {
|
|
selectCols = append(selectCols, alias+".team_id")
|
|
}
|
|
}
|
|
} else {
|
|
// Include default columns for all tables
|
|
for _, table := range parsed.Tables {
|
|
selectCols = append(selectCols, defaultColumns[table]...)
|
|
}
|
|
}
|
|
|
|
// Build FROM clause with main table
|
|
mainTable := parsed.Tables[0]
|
|
mainAlias := aliases[mainTable]
|
|
fromClause = append(fromClause, mainTable+" "+mainAlias)
|
|
|
|
// Build JOIN clauses for additional tables
|
|
for i := 1; i < len(parsed.Tables); i++ {
|
|
table := parsed.Tables[i]
|
|
alias := aliases[table]
|
|
|
|
// Define join conditions based on relationships
|
|
joinCondition := getJoinCondition(mainTable, mainAlias, table, alias)
|
|
if joinCondition != "" {
|
|
joinClauses = append(joinClauses, "LEFT JOIN "+table+" "+alias+" ON "+joinCondition)
|
|
}
|
|
}
|
|
|
|
// Build WHERE conditions
|
|
for _, condition := range parsed.Conditions {
|
|
alias := aliases[condition.Table]
|
|
whereClause := fmt.Sprintf("%s.%s %s", alias, condition.Column, condition.Operator)
|
|
|
|
switch v := condition.Value.(type) {
|
|
case string:
|
|
whereClause += fmt.Sprintf(" '%s'", strings.Replace(v, "'", "''", -1))
|
|
case int:
|
|
whereClause += fmt.Sprintf(" %d", v)
|
|
case bool:
|
|
whereClause += fmt.Sprintf(" %t", v)
|
|
case float64:
|
|
whereClause += fmt.Sprintf(" %.2f", v)
|
|
}
|
|
|
|
whereConditions = append(whereConditions, whereClause)
|
|
}
|
|
|
|
// Build GROUP BY
|
|
if len(parsed.Groupings) > 0 {
|
|
groupByClause = "GROUP BY " + strings.Join(parsed.Groupings, ", ")
|
|
}
|
|
|
|
// Build ORDER BY
|
|
if len(parsed.Sorting) > 0 {
|
|
orderByClause = "ORDER BY " + strings.Join(parsed.Sorting, ", ")
|
|
} else {
|
|
orderByClause = "ORDER BY " + mainAlias + ".created_at DESC"
|
|
}
|
|
|
|
// Construct final SQL
|
|
sql := "SELECT " + strings.Join(selectCols, ", ")
|
|
sql += " FROM " + strings.Join(fromClause, ", ")
|
|
if len(joinClauses) > 0 {
|
|
sql += " " + strings.Join(joinClauses, " ")
|
|
}
|
|
if len(whereConditions) > 0 {
|
|
sql += " WHERE " + strings.Join(whereConditions, " AND ")
|
|
}
|
|
if groupByClause != "" {
|
|
sql += " " + groupByClause
|
|
}
|
|
sql += " " + orderByClause
|
|
sql += fmt.Sprintf(" LIMIT %d", parsed.Limit)
|
|
|
|
return sql, nil
|
|
}
|
|
|
|
// getJoinCondition returns the appropriate JOIN condition between tables
|
|
func getJoinCondition(table1, alias1, table2, alias2 string) string {
|
|
joinMap := map[string]map[string]string{
|
|
"users": {
|
|
"poll": alias1 + ".user_id = " + alias2 + ".user_id",
|
|
"appointment": alias1 + ".user_id = " + alias2 + ".user_id",
|
|
"team": alias1 + ".user_id = " + alias2 + ".team_lead_id OR " + alias1 + ".user_id = " + alias2 + ".volunteer_id",
|
|
},
|
|
"poll": {
|
|
"users": alias2 + ".user_id = " + alias1 + ".user_id",
|
|
"address_database": alias1 + ".address_id = " + alias2 + ".address_id",
|
|
},
|
|
"appointment": {
|
|
"users": alias2 + ".user_id = " + alias1 + ".user_id",
|
|
"address_database": alias1 + ".address_id = " + alias2 + ".address_id",
|
|
},
|
|
"team": {
|
|
"users": alias2 + ".user_id = " + alias1 + ".team_lead_id OR " + alias2 + ".user_id = " + alias1 + ".volunteer_id",
|
|
},
|
|
"address_database": {
|
|
"poll": alias2 + ".address_id = " + alias1 + ".address_id",
|
|
"appointment": alias2 + ".address_id = " + alias1 + ".address_id",
|
|
},
|
|
}
|
|
|
|
if table1Joins, exists := joinMap[table1]; exists {
|
|
if condition, exists := table1Joins[table2]; exists {
|
|
return condition
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// executeSQLQuery executes the built SQL and returns results
|
|
func executeSQLQuery(sqlQuery string) SmartFilterResult {
|
|
rows, err := models.DB.Query(sqlQuery)
|
|
if err != nil {
|
|
log.Println("Smart filter SQL error:", err)
|
|
return SmartFilterResult{Error: "Query execution failed: " + err.Error(), Query: sqlQuery}
|
|
}
|
|
defer rows.Close()
|
|
|
|
// Get column information
|
|
columns, err := rows.Columns()
|
|
if err != nil {
|
|
return SmartFilterResult{Error: "Failed to get columns", Query: sqlQuery}
|
|
}
|
|
|
|
var results [][]interface{}
|
|
for rows.Next() {
|
|
// Create a slice of interface{} to hold the values
|
|
values := make([]interface{}, len(columns))
|
|
valuePtrs := make([]interface{}, len(columns))
|
|
for i := range values {
|
|
valuePtrs[i] = &values[i]
|
|
}
|
|
|
|
if err := rows.Scan(valuePtrs...); err != nil {
|
|
log.Println("Error scanning row:", err)
|
|
continue
|
|
}
|
|
|
|
// Convert values to strings for display
|
|
row := make([]interface{}, len(columns))
|
|
for i, val := range values {
|
|
if val == nil {
|
|
row[i] = ""
|
|
} else {
|
|
switch v := val.(type) {
|
|
case []byte:
|
|
row[i] = string(v)
|
|
case time.Time:
|
|
row[i] = v.Format("2006-01-02 15:04:05")
|
|
default:
|
|
row[i] = fmt.Sprintf("%v", v)
|
|
}
|
|
}
|
|
}
|
|
results = append(results, row)
|
|
}
|
|
|
|
return SmartFilterResult{
|
|
Columns: columns,
|
|
Rows: results,
|
|
Query: sqlQuery,
|
|
Count: len(results),
|
|
}
|
|
}
|
|
|
|
// Helper functions
|
|
func addTablesIfNeeded(parsed *SmartFilterQuery, tables []string) {
|
|
for _, table := range tables {
|
|
if !contains(parsed.Tables, table) {
|
|
parsed.Tables = append(parsed.Tables, table)
|
|
}
|
|
}
|
|
}
|
|
|
|
func contains(slice []string, item string) bool {
|
|
for _, s := range slice {
|
|
if s == item {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// getQueryExamples returns example queries for the user
|
|
func getQueryExamples() []string {
|
|
return []string{
|
|
"volunteers who went to Main Street",
|
|
"poll responses for 123 Oak Avenue",
|
|
"donations by volunteer John",
|
|
"team with most appointments",
|
|
"people in team 5",
|
|
"money made by team 3",
|
|
"visited addresses from 2024-01-01",
|
|
"active polls created after 2024-06-01",
|
|
"appointments for unvisited addresses",
|
|
"users with role admin",
|
|
"polls with donations over 100",
|
|
"addresses visited by volunteer Sarah",
|
|
"team leads with more than 5 appointments",
|
|
"donations per address",
|
|
"volunteer activity by month",
|
|
}
|
|
}
|
|
|
|
// SmartFilterAPIHandler provides JSON API for smart filtering
|
|
func SmartFilterAPIHandler(w http.ResponseWriter, r *http.Request) {
|
|
role := r.Context().Value("user_role").(int)
|
|
if role != 1 {
|
|
http.Error(w, "Unauthorized", http.StatusForbidden)
|
|
return
|
|
}
|
|
|
|
smartQuery := r.URL.Query().Get("q")
|
|
if smartQuery == "" {
|
|
http.Error(w, "Query parameter 'q' is required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
result := executeSmartFilter(smartQuery)
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
// Convert result to JSON manually since we're not using json package
|
|
response := fmt.Sprintf(`{
|
|
"columns": [%s],
|
|
"rows": [%s],
|
|
"count": %d,
|
|
"query": "%s",
|
|
"error": "%s"
|
|
}`,
|
|
formatColumnsForJSON(result.Columns),
|
|
formatRowsForJSON(result.Rows),
|
|
result.Count,
|
|
strings.Replace(result.Query, "\"", "\\\"", -1),
|
|
result.Error,
|
|
)
|
|
|
|
w.Write([]byte(response))
|
|
}
|
|
|
|
func formatColumnsForJSON(columns []string) string {
|
|
var quoted []string
|
|
for _, col := range columns {
|
|
quoted = append(quoted, fmt.Sprintf(`"%s"`, col))
|
|
}
|
|
return strings.Join(quoted, ",")
|
|
}
|
|
|
|
func formatRowsForJSON(rows [][]interface{}) string {
|
|
var rowStrings []string
|
|
for _, row := range rows {
|
|
var values []string
|
|
for _, val := range row {
|
|
values = append(values, fmt.Sprintf(`"%v"`, val))
|
|
}
|
|
rowStrings = append(rowStrings, "["+strings.Join(values, ",")+"]")
|
|
}
|
|
return strings.Join(rowStrings, ",")
|
|
}
|
|
|
|
// SmartFilterExportHandler exports smart filter results as CSV
|
|
func SmartFilterExportHandler(w http.ResponseWriter, r *http.Request) {
|
|
role := r.Context().Value("user_role").(int)
|
|
if role != 1 {
|
|
http.Error(w, "Unauthorized", http.StatusForbidden)
|
|
return
|
|
}
|
|
|
|
smartQuery := r.URL.Query().Get("smart_query")
|
|
if smartQuery == "" {
|
|
http.Error(w, "No query provided", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
result := executeSmartFilter(smartQuery)
|
|
if result.Error != "" {
|
|
http.Error(w, result.Error, http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "text/csv")
|
|
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"smart_filter_results_%s.csv\"", time.Now().Format("2006-01-02")))
|
|
|
|
// Write CSV headers
|
|
w.Write([]byte(strings.Join(result.Columns, ",") + "\n"))
|
|
|
|
// Write CSV rows
|
|
for _, row := range result.Rows {
|
|
var csvRow []string
|
|
for _, val := range row {
|
|
// Escape commas and quotes in CSV values
|
|
strVal := fmt.Sprintf("%v", val)
|
|
if strings.Contains(strVal, ",") || strings.Contains(strVal, "\"") {
|
|
strVal = "\"" + strings.Replace(strVal, "\"", "\"\"", -1) + "\""
|
|
}
|
|
csvRow = append(csvRow, strVal)
|
|
}
|
|
w.Write([]byte(strings.Join(csvRow, ",") + "\n"))
|
|
}
|
|
} |