{{.AuthorName}}
++ {{.CreatedAt.Format "Jan 2, 2006"}} +
++ {{.AuthorName}} {{.Content}} +
+commit 23f6b359cab28779c9ebade7386bd4f2c4f75107 Author: Mann Patel <130435633+Patel-Mann@users.noreply.github.com> Date: Tue Aug 26 14:13:09 2025 -0600 Initial commit diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..5008ddf Binary files /dev/null and b/.DS_Store differ diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..dfe0770 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Auto detect text files and perform LF normalization +* text=auto diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..b09cd78 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ +Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.MD b/README.MD new file mode 100644 index 0000000..5db9ae3 --- /dev/null +++ b/README.MD @@ -0,0 +1,2 @@ +# Poll-system + diff --git a/app/.DS_Store b/app/.DS_Store new file mode 100644 index 0000000..6d70eae Binary files /dev/null and b/app/.DS_Store differ diff --git a/app/.env b/app/.env new file mode 100644 index 0000000..2a92f76 --- /dev/null +++ b/app/.env @@ -0,0 +1,9 @@ +DB_USER=mannpatel +DB_HOST=localhost +DB_NAME=poll_database +DB_PASSWORD=admin +DB_PORT=5432 +PORT=3000 +JWT_SECRET=r683gi77ft92fg923keyfasdfas2r123 +ALLOWED_ORIGINS=http://localhost:5173 +NODE_ENV=development diff --git a/app/go.mod b/app/go.mod new file mode 100644 index 0000000..f0122ff --- /dev/null +++ b/app/go.mod @@ -0,0 +1,9 @@ +module github.com/patel-mann/poll-system/app + +go 1.24.4 + +require ( + github.com/golang-jwt/jwt/v5 v5.3.0 + github.com/lib/pq v1.10.9 + golang.org/x/crypto v0.41.0 +) diff --git a/app/go.sum b/app/go.sum new file mode 100644 index 0000000..0444594 --- /dev/null +++ b/app/go.sum @@ -0,0 +1,6 @@ +github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= +github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= +golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= diff --git a/app/internal/.DS_Store b/app/internal/.DS_Store new file mode 100644 index 0000000..f30de6c Binary files /dev/null and b/app/internal/.DS_Store differ diff --git a/app/internal/handlers/admin.go b/app/internal/handlers/admin.go new file mode 100644 index 0000000..8f05e3e --- /dev/null +++ b/app/internal/handlers/admin.go @@ -0,0 +1,402 @@ +package handlers + +import ( + "database/sql" + "errors" + "log" + "net/http" + "strconv" + "time" + + "github.com/patel-mann/poll-system/app/internal/models" + "github.com/patel-mann/poll-system/app/internal/utils" +) + +// View model for listing/assigning schedules +type AssignmentVM struct { + ID int + VolunteerID int + VolunteerName string + AddressID int + Address string + Date string // YYYY-MM-DD (for input[type=date]) + AppointmentTime string // HH:MM + VisitedValidated bool +} + +// GET + POST in one handler: +// - GET: show assignments + form to assign +// - POST: create a new assignment +func AdminAssignmentsHandler(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodPost: + if err := createAssignmentFromForm(r); err != nil { + log.Println("create assignment error:", err) + volunteers, _ := fetchVolunteers() + addresses, _ := fetchAddresses() + assignments, _ := fetchAssignments() + + utils.Render(w, "schedual/assignments.html", map[string]interface{}{ + "Title": "Admin — Assign Addresses", + "IsAuthenticated": true, + "ActiveSection": "admin_assignments", + "Volunteers": volunteers, + "Addresses": addresses, + "Assignments": assignments, + "Error": err.Error(), + }) + return + } + http.Redirect(w, r, "/admin/assignments", http.StatusSeeOther) + return + } + + // GET: fetch volunteers, addresses, and existing assignments + volunteers, err := fetchVolunteers() + if err != nil { + log.Println("fetch volunteers error:", err) + http.Error(w, "Failed to load volunteers", http.StatusInternalServerError) + return + } + addresses, err := fetchAddresses() + if err != nil { + log.Println("fetch addresses error:", err) + http.Error(w, "Failed to load addresses", http.StatusInternalServerError) + return + } + assignments, err := fetchAssignments() + if err != nil { + log.Println("fetch assignments error:", err) + http.Error(w, "Failed to load assignments", http.StatusInternalServerError) + return + } + + utils.Render(w, "assignments.html", map[string]interface{}{ + "Title": "Admin — Assign Addresses", + "IsAuthenticated": true, + "ActiveSection": "admin_assignments", + "Volunteers": volunteers, + "Addresses": addresses, + "Assignments": assignments, + }) +} + +// GET (edit form) + POST (update/delete) +func AdminAssignmentEditHandler(w http.ResponseWriter, r *http.Request) { + idStr := r.URL.Query().Get("id") + id, _ := strconv.Atoi(idStr) + if id <= 0 { + http.NotFound(w, r) + return + } + + if r.Method == http.MethodPost { + action := r.FormValue("action") + switch action { + case "delete": + if err := deleteAssignment(id); err != nil { + log.Println("delete assignment error:", err) + http.Error(w, "Failed to delete assignment", http.StatusInternalServerError) + return + } + http.Redirect(w, r, "/admin/assignments", http.StatusSeeOther) + return + case "update": + if err := updateAssignmentFromForm(id, r); err != nil { + log.Println("update assignment error:", err) + vm, _ := fetchAssignmentByID(id) + volunteers, _ := fetchVolunteers() + addresses, _ := fetchAddresses() + + utils.Render(w, "assignment_edit.html", map[string]interface{}{ + "Title": "Edit Assignment", + "Assignment": vm, + "Volunteers": volunteers, + "Addresses": addresses, + "Error": err.Error(), + }) + return + } + http.Redirect(w, r, "/admin/assignments", http.StatusSeeOther) + return + default: + http.Error(w, "Unknown action", http.StatusBadRequest) + return + } + } + + // GET edit + vm, err := fetchAssignmentByID(id) + if err != nil { + if err == sql.ErrNoRows { + http.NotFound(w, r) + return + } + log.Println("fetch assignment by ID error:", err) + http.Error(w, "Failed to load assignment", http.StatusInternalServerError) + return + } + + volunteers, err := fetchVolunteers() + if err != nil { + log.Println("fetch volunteers error:", err) + http.Error(w, "Failed to load volunteers", http.StatusInternalServerError) + return + } + addresses, err := fetchAddresses() + if err != nil { + log.Println("fetch addresses error:", err) + http.Error(w, "Failed to load addresses", http.StatusInternalServerError) + return + } + + utils.Render(w, "assignment_edit.html", map[string]interface{}{ + "Title": "Edit Assignment", + "Assignment": vm, + "Volunteers": volunteers, + "Addresses": addresses, + }) +} + +// ----- Helpers ----- + +func createAssignmentFromForm(r *http.Request) error { + volID, _ := strconv.Atoi(r.FormValue("volunteer_id")) + addrID, _ := strconv.Atoi(r.FormValue("address_id")) + dateStr := r.FormValue("date") + timeStr := r.FormValue("appointment_time") + + if volID <= 0 || addrID <= 0 || dateStr == "" || timeStr == "" { + return errors.New("please fill all required fields") + } + + if _, err := time.Parse("2006-01-02", dateStr); err != nil { + return errors.New("invalid date format") + } + if _, err := time.Parse("15:04", timeStr); err != nil { + return errors.New("invalid time format") + } + + _, err := models.DB.Exec(` + INSERT INTO schedual (user_id, address_id, appointment_date, appointment_time, created_at, updated_at) + VALUES ($1,$2,$3,$4,NOW(),NOW()) + `, volID, addrID, dateStr, timeStr) + + if err != nil { + log.Println("database insert error:", err) + return errors.New("failed to create assignment") + } + + return nil +} + +func updateAssignmentFromForm(id int, r *http.Request) error { + volID, _ := strconv.Atoi(r.FormValue("volunteer_id")) + addrID, _ := strconv.Atoi(r.FormValue("address_id")) + dateStr := r.FormValue("date") + timeStr := r.FormValue("appointment_time") + + if volID <= 0 || addrID <= 0 || dateStr == "" || timeStr == "" { + return errors.New("please fill all required fields") + } + + if _, err := time.Parse("2006-01-02", dateStr); err != nil { + return errors.New("invalid date format") + } + if _, err := time.Parse("15:04", timeStr); err != nil { + return errors.New("invalid time format") + } + + result, err := models.DB.Exec(` + UPDATE schedual + SET user_id=$1, address_id=$2, appointment_date=$3, appointment_time=$4, updated_at=NOW() + WHERE schedual_id=$5 + `, volID, addrID, dateStr, timeStr, id) + + if err != nil { + log.Println("database update error:", err) + return errors.New("failed to update assignment") + } + + rowsAffected, _ := result.RowsAffected() + if rowsAffected == 0 { + return errors.New("assignment not found") + } + return nil +} + +func deleteAssignment(id int) error { + result, err := models.DB.Exec(`DELETE FROM schedual WHERE schedual_id=$1`, id) + if err != nil { + log.Println("database delete error:", err) + return errors.New("failed to delete assignment") + } + rowsAffected, _ := result.RowsAffected() + if rowsAffected == 0 { + return errors.New("assignment not found") + } + return nil +} + +// Fetch volunteers +type VolunteerPick struct { + ID int + FirstName string + LastName string + Email string +} + +func fetchVolunteers() ([]VolunteerPick, error) { + rows, err := models.DB.Query(` + SELECT users_id, first_name, last_name, email + FROM "user" + WHERE role='volunteer' + ORDER BY first_name, last_name + `) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []VolunteerPick + for rows.Next() { + var v VolunteerPick + if err := rows.Scan(&v.ID, &v.FirstName, &v.LastName, &v.Email); err != nil { + log.Println("fetchVolunteers scan:", err) + continue + } + out = append(out, v) + } + return out, rows.Err() +} + +// Fetch addresses +type AddressPick struct { + ID int + Label string + VisitedValidated bool +} + +func fetchAddresses() ([]AddressPick, error) { + rows, err := models.DB.Query(` + SELECT + address_id, + address, + street_name, + street_type, + street_quadrant, + house_number, + house_alpha, + longitude, + latitude, + visited_validated + FROM address_database + ORDER BY address_id DESC + `) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []AddressPick + for rows.Next() { + var addr models.AddressDatabase + if err := rows.Scan( + &addr.AddressID, + &addr.Address, + &addr.StreetName, + &addr.StreetType, + &addr.StreetQuadrant, + &addr.HouseNumber, + &addr.HouseAlpha, + &addr.Longitude, + &addr.Latitude, + &addr.VisitedValidated, + ); err != nil { + log.Println("fetchAddresses scan:", err) + continue + } + + label := addr.Address + if label == "" { + label = addr.HouseNumber + if addr.StreetName != "" { + if label != "" { + label += " " + } + label += addr.StreetName + } + if addr.StreetType != "" { + label += " " + addr.StreetType + } + if addr.StreetQuadrant != "" { + label += " " + addr.StreetQuadrant + } + if addr.HouseAlpha != nil { + label += " " + *addr.HouseAlpha + } + } + + out = append(out, AddressPick{ + ID: addr.AddressID, + Label: label, + VisitedValidated: addr.VisitedValidated, + }) + } + return out, rows.Err() +} + +// Add this missing function +func fetchAssignments() ([]AssignmentVM, error) { + rows, err := models.DB.Query(` + SELECT + s.schedual_id, + u.users_id, + COALESCE(u.first_name,'') || ' ' || COALESCE(u.last_name,'') AS volunteer_name, + a.address_id, + COALESCE(a.address,'') AS address, + s.appointment_date, + s.appointment_time + FROM schedual s + JOIN "user" u ON u.users_id = s.user_id + JOIN address_database a ON a.address_id = s.address_id + ORDER BY s.appointment_date DESC, s.appointment_time DESC + `) + if err != nil { + return nil, err + } + defer rows.Close() + + var assignments []AssignmentVM + for rows.Next() { + var vm AssignmentVM + if err := rows.Scan(&vm.ID, &vm.VolunteerID, &vm.VolunteerName, &vm.AddressID, &vm.Address, + &vm.Date, &vm.AppointmentTime); err != nil { + log.Println("fetchAssignments scan:", err) + continue + } + assignments = append(assignments, vm) + } + return assignments, rows.Err() +} + +func fetchAssignmentByID(id int) (AssignmentVM, error) { + var vm AssignmentVM + err := models.DB.QueryRow(` + SELECT + s.schedual_id, + u.users_id, + COALESCE(u.first_name,'') || ' ' || COALESCE(u.last_name,'') AS volunteer_name, + a.address_id, + COALESCE(a.address,'') AS address, + s.appointment_date, + s.appointment_time + FROM schedual s + JOIN "user" u ON u.users_id = s.user_id + JOIN address_database a ON a.address_id = s.address_id + WHERE s.schedual_id = $1 + `, id).Scan(&vm.ID, &vm.VolunteerID, &vm.VolunteerName, &vm.AddressID, &vm.Address, + &vm.Date, &vm.AppointmentTime) + + return vm, err +} diff --git a/app/internal/handlers/admin_addresses.go b/app/internal/handlers/admin_addresses.go new file mode 100644 index 0000000..c53c708 --- /dev/null +++ b/app/internal/handlers/admin_addresses.go @@ -0,0 +1,184 @@ +package handlers + +import ( + "log" + "net/http" + "strconv" + + "github.com/patel-mann/poll-system/app/internal/models" + "github.com/patel-mann/poll-system/app/internal/utils" +) + +// PaginationInfo holds pagination metadata +type PaginationInfo struct { + CurrentPage int + TotalPages int + TotalRecords int + PageSize int + HasPrevious bool + HasNext bool + StartRecord int + EndRecord int + PreviousPage int + NextPage int + FirstPage int + LastPage int + PageNumbers []PageNumber +} + +type PageNumber struct { + Number int + IsCurrent bool +} + +func AddressHandler(w http.ResponseWriter, r *http.Request) { + // Get pagination parameters from query string + pageStr := r.URL.Query().Get("page") + pageSizeStr := r.URL.Query().Get("pageSize") + + // Default values + page := 1 + pageSize := 20 // Default page size + + // Parse page number + if pageStr != "" { + if p, err := strconv.Atoi(pageStr); err == nil && p > 0 { + page = p + } + } + + // Parse page size + if pageSizeStr != "" { + if ps, err := strconv.Atoi(pageSizeStr); err == nil && ps > 0 && ps <= 100 { + pageSize = ps + } + } + + // Calculate offset + offset := (page - 1) * pageSize + + // Get total count first + var totalRecords int + err := models.DB.QueryRow(`SELECT COUNT(*) FROM "address_database"`).Scan(&totalRecords) + if err != nil { + log.Println("Count query error:", err) + http.Error(w, "Database error", http.StatusInternalServerError) + return + } + + // Calculate pagination info + totalPages := (totalRecords + pageSize - 1) / pageSize + if totalPages == 0 { + totalPages = 1 + } + + // Ensure current page is within bounds + if page > totalPages { + page = totalPages + offset = (page - 1) * pageSize + } + + // Get paginated results + rows, err := models.DB.Query(` + SELECT address_id, address, street_name, street_type, + street_quadrant, house_number, house_alpha, longitude, + latitude, visited_validated + FROM "address_database" + WHERE street_quadrant = 'ne' + ORDER BY address_id + LIMIT $1 OFFSET $2 + `, pageSize, offset) + if err != nil { + log.Println("Query error:", err) + http.Error(w, "Database error", http.StatusInternalServerError) + return + } + defer rows.Close() + + var addresses []models.AddressDatabase + for rows.Next() { + var a models.AddressDatabase + err := rows.Scan( + &a.AddressID, + &a.Address, + &a.StreetName, + &a.StreetType, + &a.StreetQuadrant, + &a.HouseNumber, + &a.HouseAlpha, + &a.Longitude, + &a.Latitude, + &a.VisitedValidated, + ) + if err != nil { + log.Println("Scan error:", err) + continue + } + addresses = append(addresses, a) + } + + // Calculate start and end record numbers for display + startRecord := offset + 1 + endRecord := offset + len(addresses) + if totalRecords == 0 { + startRecord = 0 + } + + // Generate page numbers for pagination controls + pageNumbers := generatePageNumbers(page, totalPages) + + pagination := PaginationInfo{ + CurrentPage: page, + TotalPages: totalPages, + TotalRecords: totalRecords, + PageSize: pageSize, + HasPrevious: page > 1, + HasNext: page < totalPages, + StartRecord: startRecord, + EndRecord: endRecord, + PreviousPage: page - 1, + NextPage: page + 1, + FirstPage: 1, + LastPage: totalPages, + PageNumbers: pageNumbers, + } + + utils.Render(w, "address/address.html", map[string]interface{}{ + "Title": "Addresses", + "IsAuthenticated": true, + "ShowAdminNav": true, + "ActiveSection": "address", // Add this line + "Addresses": addresses, + "Role": "admin", + "Pagination": pagination, + }) +} + +func generatePageNumbers(currentPage, totalPages int) []PageNumber { + var pageNumbers []PageNumber + + // Generate page numbers to show (max 7 pages) + start := currentPage - 3 + end := currentPage + 3 + + if start < 1 { + end += 1 - start + start = 1 + } + if end > totalPages { + start -= end - totalPages + end = totalPages + } + if start < 1 { + start = 1 + } + + for i := start; i <= end; i++ { + pageNumbers = append(pageNumbers, PageNumber{ + Number: i, + IsCurrent: i == currentPage, + }) + } + + return pageNumbers +} diff --git a/app/internal/handlers/admin_dashboard.go b/app/internal/handlers/admin_dashboard.go new file mode 100644 index 0000000..fd10f5e --- /dev/null +++ b/app/internal/handlers/admin_dashboard.go @@ -0,0 +1,81 @@ +package handlers + +import ( + "log" + "net/http" + + "github.com/patel-mann/poll-system/app/internal/models" + "github.com/patel-mann/poll-system/app/internal/utils" +) + + +func AdminDashboardHandler(w http.ResponseWriter, r *http.Request) { + currentAdminID := r.Context().Value("user_id").(int) + + role, _ := r.Context().Value("uesr_role").(int) + + var volunteerCount int + var totalDonations float64 + var validatedCount int + var housesLeftPercent float64 + + // 1. Count volunteers assigned to this admin + err := models.DB.QueryRow(` + SELECT COUNT(av.volunteer_id) + FROM admin_volunteers av + WHERE av.admin_id = $1 AND av.is_active = TRUE; + `, currentAdminID).Scan(&volunteerCount) + if err != nil { + log.Println("Volunteer query error:", err) + volunteerCount = 0 // Set default value on error + } + + // 2. Total donations from polls + err = models.DB.QueryRow(` + SELECT COALESCE(SUM(amount_donated), 0) + FROM poll; + `).Scan(&totalDonations) + if err != nil { + log.Println("Donation query error:", err) + totalDonations = 0 // Set default value on error + } + + // 3. Count validated addresses + err = models.DB.QueryRow(` + SELECT COUNT(*) + FROM address_database + WHERE visited_validated = TRUE; + `).Scan(&validatedCount) + if err != nil { + log.Println("Validated addresses query error:", err) + validatedCount = 0 // Set default value on error + } + + // 4. Calculate percentage of houses left to visit + err = models.DB.QueryRow(` + SELECT + CASE + WHEN COUNT(*) = 0 THEN 0 + ELSE ROUND( + (COUNT(*) FILTER (WHERE visited_validated = FALSE)::numeric / COUNT(*)::numeric) * 100, 2 + ) + END + FROM address_database; + `).Scan(&housesLeftPercent) + if err != nil { + log.Println("Houses left query error:", err) + housesLeftPercent = 0 // Set default value on error + } + + utils.Render(w, "dashboard/dashboard.html", map[string]interface{}{ + "Title": "Admin Dashboard", + "IsAuthenticated": true, + "VolunteerCount": volunteerCount, + "TotalDonations": totalDonations, + "ValidatedCount": validatedCount, + "HousesLeftPercent": housesLeftPercent, + "ShowAdminNav": true, + "Role": role, + "ActiveSection": "dashboard", + }) +} \ No newline at end of file diff --git a/app/internal/handlers/admin_post.go b/app/internal/handlers/admin_post.go new file mode 100644 index 0000000..9cbd632 --- /dev/null +++ b/app/internal/handlers/admin_post.go @@ -0,0 +1,160 @@ +// Updated admin_post.go with better image handling + +package handlers + +import ( + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/patel-mann/poll-system/app/internal/models" + "github.com/patel-mann/poll-system/app/internal/utils" +) + +func PostsHandler(w http.ResponseWriter, r *http.Request) { + userID := r.Context().Value("user_id").(int) + role := r.Context().Value("user_role").(int) + + if r.Method == http.MethodPost { + // Parse multipart form + err := r.ParseMultipartForm(10 << 20) // 10MB max + if err != nil { + fmt.Printf("Error parsing form: %v\n", err) + http.Error(w, "Invalid form", http.StatusBadRequest) + return + } + + content := r.FormValue("content") + if strings.TrimSpace(content) == "" { + http.Error(w, "Content cannot be empty", http.StatusBadRequest) + return + } + + var imagePath string + file, handler, err := r.FormFile("image") + if err == nil && file != nil { + defer file.Close() + + // Validate file type + allowedTypes := map[string]bool{ + ".jpg": true, + ".jpeg": true, + ".png": true, + ".gif": true, + ".webp": true, + } + + ext := strings.ToLower(filepath.Ext(handler.Filename)) + if !allowedTypes[ext] { + http.Error(w, "Invalid file type. Only images allowed.", http.StatusBadRequest) + return + } + + // Ensure uploads folder exists + uploadDir := "uploads" + if err := os.MkdirAll(uploadDir, 0755); err != nil { + fmt.Printf("Error creating upload directory: %v\n", err) + http.Error(w, "Unable to create upload directory", http.StatusInternalServerError) + return + } + + // Create unique filename + filename := fmt.Sprintf("%d_%d%s", userID, time.Now().UnixNano(), ext) + savePath := filepath.Join(uploadDir, filename) + + out, err := os.Create(savePath) + if err != nil { + fmt.Printf("Error creating file: %v\n", err) + http.Error(w, "Unable to save file", http.StatusInternalServerError) + return + } + defer out.Close() + + _, err = io.Copy(out, file) + if err != nil { + fmt.Printf("Error copying file: %v\n", err) + http.Error(w, "Failed to save file", http.StatusInternalServerError) + return + } + + // Save path relative to the static route + imagePath = "/uploads/" + filename + fmt.Printf("Image saved at: %s\n", imagePath) + } else if err != http.ErrMissingFile { + fmt.Printf("Error getting file: %v\n", err) + } + + // Insert post + _, err = models.DB.Exec(`INSERT INTO post (author_id, content, image_url) VALUES ($1, $2, $3)`, + userID, content, imagePath) + if err != nil { + fmt.Printf("Database error: %v\n", err) + http.Error(w, "Failed to create post", http.StatusInternalServerError) + return + } + + fmt.Printf("Post created successfully with image: %s\n", imagePath) + http.Redirect(w, r, "/posts", http.StatusSeeOther) + return + } + + // GET request: fetch posts + rows, err := models.DB.Query(` + SELECT p.post_id, p.author_id, u.first_name || ' ' || u.last_name AS author_name, + p.content, COALESCE(p.image_url, '') as image_url, p.created_at + FROM post p + JOIN users u ON p.author_id = u.user_id + ORDER BY p.created_at DESC + `) + if err != nil { + fmt.Printf("Database query error: %v\n", err) + http.Error(w, "Failed to fetch posts", http.StatusInternalServerError) + return + } + defer rows.Close() + + var posts []models.Post + for rows.Next() { + var p models.Post + err := rows.Scan(&p.PostID, &p.AuthorID, &p.AuthorName, &p.Content, &p.ImageURL, &p.CreatedAt) + if err != nil { + fmt.Printf("Row scan error: %v\n", err) + continue + } + posts = append(posts, p) + } + + // Add cache busting parameter to image URLs + for i := range posts { + if posts[i].ImageURL != "" { + posts[i].ImageURL += "?t=" + strconv.FormatInt(time.Now().UnixNano(), 10) + fmt.Printf("Post %d image URL: %s\n", posts[i].PostID, posts[i].ImageURL) + } + } + + // Get navigation flags + showAdminNav, showVolunteerNav := getNavFlags(role) + + fmt.Printf("Rendering %d posts\n", len(posts)) + + utils.Render(w, "posts.html", map[string]interface{}{ + "Title": "Posts", + "IsAuthenticated": true, + "ShowAdminNav": showAdminNav, + "ShowVolunteerNav": showVolunteerNav, + "Posts": posts, + "ActiveSection": "posts", + }) +} + +// Helper function (add this to your main.go if not already there) +func getNavFlags(role int) (bool, bool) { + showAdminNav := role == 1 // Admin role + showVolunteerNav := role == 3 // Volunteer role + return showAdminNav, showVolunteerNav +} \ No newline at end of file diff --git a/app/internal/handlers/admin_voluteers.go b/app/internal/handlers/admin_voluteers.go new file mode 100644 index 0000000..8e370f1 --- /dev/null +++ b/app/internal/handlers/admin_voluteers.go @@ -0,0 +1,219 @@ +package handlers + +import ( + "fmt" + "log" + "net/http" + "strconv" + + "github.com/patel-mann/poll-system/app/internal/models" + "github.com/patel-mann/poll-system/app/internal/utils" +) + + +func VolunteerHandler(w http.ResponseWriter, r *http.Request) { + // TODO: Replace this with actual session/jwt extraction + currentAdminID := r.Context().Value("user_id").(int) + + rows, err := models.DB.Query(` + SELECT u.user_id, u.email, u.role_id, u.first_name, u.last_name, u.phone + FROM "users" u + JOIN admin_volunteers av ON u.user_id = av.volunteer_id + WHERE av.admin_id = $1 AND ( u.role_id = 3 OR u.role_id = 2 ) + `, currentAdminID) + if err != nil { + http.Error(w, "Query error", http.StatusInternalServerError) + return + } + defer rows.Close() + + var user []models.User + for rows.Next() { + var b models.User + err := rows.Scan(&b.UserID, &b.Email, &b.RoleID, &b.FirstName, &b.LastName, &b.Phone) + if err != nil { + log.Println("Scan error:", err) + continue + } + user = append(user, b) + } + + utils.Render(w, "volunteer/volunteer.html", map[string]interface{}{ + "Title": "Assigned Volunteers", + "IsAuthenticated": true, + "ShowAdminNav": true, + "Users": user, + "ActiveSection": "volunteer", + }) +} + +func EditVolunteerHandler(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + volunteerID := r.URL.Query().Get("id") + var user models.User + err := models.DB.QueryRow(` + SELECT user_id, email, role_id, first_name, last_name, phone + FROM "users" + WHERE user_id = $1 AND (role_id = 3 OR role_id = 2) + `, volunteerID).Scan(&user.UserID, &user.Email, &user.RoleID, &user.FirstName, &user.LastName, &user.Phone) + + if err != nil { + http.Error(w, "Volunteer not found", http.StatusNotFound) + return + } + + utils.Render(w, "volunteer/edit_volunteer.html", map[string]interface{}{ + "Title": "Edit Volunteer", + "IsAuthenticated": true, + "ShowAdminNav": true, + "Volunteer": user, + "ActiveSection": "volunteer", + }) + return + } + + if r.Method == http.MethodPost { + err := r.ParseForm() + if err != nil { + http.Error(w, "Invalid form", http.StatusBadRequest) + return + } + + volunteerID := r.FormValue("user_id") + firstName := r.FormValue("first_name") + lastName := r.FormValue("last_name") + email := r.FormValue("email") + phone := r.FormValue("phone") + roleID := r.FormValue("role_id") + + rid, err := strconv.Atoi(roleID) + if err != nil || (rid != 2 && rid != 3) { + http.Error(w, "Invalid role selection", http.StatusBadRequest) + return + } + + _, err = models.DB.Exec(` + UPDATE "users" + SET first_name = $1, last_name = $2, email = $3, phone = $4, role_id = $5 + WHERE user_id = $6 + `, firstName, lastName, email, phone, rid, volunteerID) + + if err != nil { + fmt.Print(err) + http.Error(w, "Update failed", http.StatusInternalServerError) + return + } + + http.Redirect(w, r, "/volunteers", http.StatusSeeOther) + } +} + +type User struct { + ID int + Name string +} + +type TeamLead struct { + ID int + Name string + Volunteers []User +} + +type TeamBuilderData struct { + TeamLeads []TeamLead + UnassignedVolunteers []User +} + + + +func TeamBuilderHandler(w http.ResponseWriter, r *http.Request) { + // GET request: show team leads and unassigned volunteers + if r.Method == http.MethodGet { + var teamLeads []TeamLead + var unassignedVolunteers []User + + // Get all team leads (role_id = 2) + tlRows, err := models.DB.Query(`SELECT user_id, first_name || ' ' || last_name AS name FROM users WHERE role_id = 2`) + if err != nil { + http.Error(w, "Error fetching team leads", http.StatusInternalServerError) + return + } + defer tlRows.Close() + for tlRows.Next() { + var tl TeamLead + tlRows.Scan(&tl.ID, &tl.Name) + + // Get assigned volunteers for this team lead + vRows, _ := models.DB.Query(`SELECT u.user_id, u.first_name || ' ' || u.last_name AS name + FROM users u + JOIN team t ON u.user_id = t.volunteer_id + WHERE t.team_lead_id = $1`, tl.ID) + + for vRows.Next() { + var vol User + vRows.Scan(&vol.ID, &vol.Name) + tl.Volunteers = append(tl.Volunteers, vol) + } + + teamLeads = append(teamLeads, tl) + } + + // Get unassigned volunteers (role_id = 3) + vRows, _ := models.DB.Query(`SELECT user_id, first_name || ' ' || last_name AS name + FROM users + WHERE role_id = 3 + AND user_id NOT IN (SELECT volunteer_id FROM team)`) + for vRows.Next() { + var vol User + vRows.Scan(&vol.ID, &vol.Name) + unassignedVolunteers = append(unassignedVolunteers, vol) + } + + utils.Render(w, "volunteer/team_builder.html", map[string]interface{}{ + "Title": "Team Builder", + "IsAuthenticated": true, + "ShowAdminNav": true, + "TeamLeads": teamLeads, + "UnassignedVolunteers": unassignedVolunteers, + "ActiveSection": "team_builder", + }) + return + } + + // POST request: assign volunteer to a team lead + if r.Method == http.MethodPost { + if err := r.ParseForm(); err != nil { + http.Error(w, "Invalid form", http.StatusBadRequest) + return + } + + volunteerID, err := strconv.Atoi(r.FormValue("volunteer_id")) + if err != nil { + http.Error(w, "Invalid volunteer ID", http.StatusBadRequest) + return + } + teamLeadID, err := strconv.Atoi(r.FormValue("team_lead_id")) + if err != nil { + http.Error(w, "Invalid team lead ID", http.StatusBadRequest) + return + } + + _, err = models.DB.Exec(`INSERT INTO team (volunteer_id, team_lead_id) VALUES ($1, $2)`, volunteerID, teamLeadID) + if err != nil { + fmt.Println(err) + http.Error(w, "Failed to assign volunteer", http.StatusInternalServerError) + return + } + + http.Redirect(w, r, "/team_builder", http.StatusSeeOther) + } +} + + + + + +//assign volunterr the title of team_leader +//Team View +//edit volnteer data +// diff --git a/app/internal/handlers/login.go b/app/internal/handlers/login.go new file mode 100644 index 0000000..5fcef08 --- /dev/null +++ b/app/internal/handlers/login.go @@ -0,0 +1,367 @@ +package handlers + +import ( + "context" + "log" + "net/http" + "time" + + "github.com/golang-jwt/jwt/v5" + "golang.org/x/crypto/bcrypt" + + "github.com/patel-mann/poll-system/app/internal/models" + "github.com/patel-mann/poll-system/app/internal/utils" +) + +var jwtKey = []byte("your-secret-key") //TODO: Move to env/config + +// Helper function to get redirect URL based on role +func getDefaultRedirectURL(role int) string { + switch role { + case 1: // Admin + return "/dashboard" + case 2: // Volunteer + return "/volunteer/dashboard" + case 3: // Volunteer + return "/volunteer/dashboard" + default: + return "/" // Fallback to login page + } +} + +// Helper function to render error pages with consistent data +func renderLoginError(w http.ResponseWriter, errorMsg string) { + utils.Render(w, "login.html", map[string]interface{}{ + "Error": errorMsg, + "Title": "Login", + "IsAuthenticated": false, + }) +} + +func renderRegisterError(w http.ResponseWriter, errorMsg string) { + utils.Render(w, "register.html", map[string]interface{}{ + "Error": errorMsg, + "Title": "Register", + "IsAuthenticated": false, + }) +} + +// Helper function to create and sign JWT token +func createJWTToken(userID, role int) (string, time.Time, error) { + expirationTime := time.Now().Add(12 * time.Hour) + claims := &models.Claims{ + UserID: userID, + Role: role, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(expirationTime), + IssuedAt: jwt.NewNumericDate(time.Now()), + }, + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + tokenString, err := token.SignedString(jwtKey) + return tokenString, expirationTime, err +} + +// Helper function to set session cookie +func setSessionCookie(w http.ResponseWriter, tokenString string, expirationTime time.Time) { + http.SetCookie(w, &http.Cookie{ + Name: "session", + Value: tokenString, + Path: "/", + HttpOnly: true, + Secure: false, // Set to true in production with HTTPS + SameSite: http.SameSiteStrictMode, + Expires: expirationTime, + }) +} + +// Helper function to clear session cookie +func clearSessionCookie(w http.ResponseWriter) { + http.SetCookie(w, &http.Cookie{ + Name: "session", + Value: "", + Path: "/", + MaxAge: -1, + HttpOnly: true, + Secure: false, // Set to true in production with HTTPS + SameSite: http.SameSiteStrictMode, + }) +} + +// func LoginPage(w http.ResponseWriter, r *http.Request) { +// utils.Render(w, "login.html", map[string]interface{}{ +// "Title": "Login", +// "IsAuthenticated": false, +// }) +// } + +func LoginHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Redirect(w, r, "/", http.StatusSeeOther) + return + } + + email := r.FormValue("email") + password := r.FormValue("password") + + // Input validation + if email == "" || password == "" { + renderLoginError(w, "Email and password are required") + return + } + + // Get user from database + var storedHash string + var userID int + var role int + + err := models.DB.QueryRow(` + SELECT user_id, password, role_id + FROM "users" + WHERE email = $1 + `, email).Scan(&userID, &storedHash, &role) + + if err != nil { + log.Printf("Login failed for email %s: %v", email, err) + renderLoginError(w, "Invalid email or password") + return + } + + // Verify password + err = bcrypt.CompareHashAndPassword([]byte(storedHash), []byte(password)) + if err != nil { + log.Printf("Password verification failed for user ID %d", userID) + renderLoginError(w, "Invalid email or password") + return + } + + // Create JWT token + tokenString, expirationTime, err := createJWTToken(userID, role) + if err != nil { + log.Printf("JWT token creation failed for user ID %d: %v", userID, err) + http.Error(w, "Could not log in", http.StatusInternalServerError) + return + } + + // Set session cookie + setSessionCookie(w, tokenString, expirationTime) + + // Redirect based on user role + redirectURL := getDefaultRedirectURL(role) + log.Printf("User %d (role %d) logged in successfully, redirecting to %s", userID, role, redirectURL) + http.Redirect(w, r, redirectURL, http.StatusSeeOther) +} + +func RegisterHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + utils.Render(w, "register.html", map[string]interface{}{ + "Title": "Register", + "IsAuthenticated": false, + }) + return + } + + firstName := r.FormValue("first_name") + lastName := r.FormValue("last_name") + email := r.FormValue("email") + phone := r.FormValue("phone") + role := r.FormValue("role") + password := r.FormValue("password") + + // Input validation + if firstName == "" || lastName == "" || email == "" || password == "" || role == "" { + renderRegisterError(w, "All fields are required") + return + } + + // Hash password + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + log.Printf("Password hashing failed: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + // Insert user into database + _, err = models.DB.Exec(` + INSERT INTO "users" (first_name, last_name, email, phone, password, role_id) + VALUES ($1, $2, $3, $4, $5, $6) + `, firstName, lastName, email, phone, string(hashedPassword), role) + + if err != nil { + log.Printf("User registration failed for email %s: %v", email, err) + renderRegisterError(w, "Could not create account. Email might already be in use.") + return + } + + log.Printf("User registered successfully: %s %s (%s)", firstName, lastName, email) + http.Redirect(w, r, "/", http.StatusSeeOther) +} + +func LogoutHandler(w http.ResponseWriter, r *http.Request) { + clearSessionCookie(w) + http.Redirect(w, r, "/", http.StatusSeeOther) +} + +// // Admin Dashboard Handler +// func AdminDashboardHandler(w http.ResponseWriter, r *http.Request) { +// role := r.Context().Value("user_role").(int) +// userID := r.Context().Value("user_id").(int) + +// // TODO: Fetch real data from database +// dashboardData := map[string]interface{}{ +// "UserID": userID, +// "TotalUsers": 100, // Example: get from database +// "TotalVolunteers": 50, // Example: get from database +// "TotalAddresses": 200, // Example: get from database +// "RecentActivity": []string{"User logged in", "New volunteer registered"}, // Example +// } + +// data := createTemplateData("Admin Dashboard", "dashboard", role, true, dashboardData) +// utils.Render(w, "dashboard/dashboard.html", data) +// } + +// // Volunteer Management Handler +// func VolunteerHandler(w http.ResponseWriter, r *http.Request) { +// role := r.Context().Value("user_role").(int) + +// // TODO: Fetch real volunteer data from database +// volunteerData := map[string]interface{}{ +// "Volunteers": []map[string]interface{}{ +// {"ID": 1, "Name": "John Doe", "Email": "john@example.com", "Status": "Active"}, +// {"ID": 2, "Name": "Jane Smith", "Email": "jane@example.com", "Status": "Active"}, +// }, // Example: get from database +// } + +// data := createTemplateData("Volunteers", "volunteer", role, true, volunteerData) +// utils.Render(w, "volunteers/volunteers.html", data) +// } + +// // Address Management Handler +// func AddressHandler(w http.ResponseWriter, r *http.Request) { +// role := r.Context().Value("user_role").(int) + +// // TODO: Fetch real address data from database +// addressData := map[string]interface{}{ +// "Addresses": []map[string]interface{}{ +// {"ID": 1, "Street": "123 Main St", "City": "Calgary", "Status": "Validated"}, +// {"ID": 2, "Street": "456 Oak Ave", "City": "Calgary", "Status": "Pending"}, +// }, // Example: get from database +// } + +// data := createTemplateData("Addresses", "address", role, true, addressData) +// utils.Render(w, "addresses/addresses.html", data) +// } + +// // Reports Handler +// func ReportHandler(w http.ResponseWriter, r *http.Request) { +// role := r.Context().Value("user_role").(int) + +// // TODO: Fetch real report data from database +// reportData := map[string]interface{}{ +// "Reports": []map[string]interface{}{ +// {"ID": 1, "Name": "Weekly Summary", "Date": "2025-08-25", "Status": "Complete"}, +// {"ID": 2, "Name": "Monthly Analytics", "Date": "2025-08-01", "Status": "Pending"}, +// }, // Example: get from database +// } + +// data := createTemplateData("Reports", "report", role, true, reportData) +// utils.Render(w, "reports/reports.html", data) +// } + +// // Profile Handler (works for both admin and volunteer) +// func ProfileHandler(w http.ResponseWriter, r *http.Request) { +// role := r.Context().Value("user_role").(int) +// userID := r.Context().Value("user_id").(int) + +// // Fetch real user data from database +// var firstName, lastName, email, phone string +// err := models.DB.QueryRow(` +// SELECT first_name, last_name, email, phone +// FROM "users" +// WHERE user_id = $1 +// `, userID).Scan(&firstName, &lastName, &email, &phone) + +// profileData := map[string]interface{}{ +// "UserID": userID, +// } + +// if err != nil { +// log.Printf("Error fetching user profile for ID %d: %v", userID, err) +// profileData["Error"] = "Could not load profile data" +// } else { +// profileData["FirstName"] = firstName +// profileData["LastName"] = lastName +// profileData["Email"] = email +// profileData["Phone"] = phone +// } + +// data := createTemplateData("Profile", "profile", role, true, profileData) +// utils.Render(w, "profile/profile.html", data) +// } + +// // Volunteer Dashboard Handler +// func VolunteerDashboardHandler(w http.ResponseWriter, r *http.Request) { +// role := r.Context().Value("user_role").(int) +// userID := r.Context().Value("user_id").(int) + +// // TODO: Fetch volunteer-specific data from database +// dashboardData := map[string]interface{}{ +// "UserID": userID, +// "AssignedTasks": 5, // Example: get from database +// "CompletedTasks": 12, // Example: get from database +// "UpcomingEvents": []string{"Community Meeting - Aug 30", "Training Session - Sep 5"}, // Example +// } + +// data := createTemplateData("Volunteer Dashboard", "dashboard", role, true, dashboardData) +// utils.Render(w, "volunteer/dashboard.html", data) +// } + +// // Schedule Handler for Volunteers +// func ScheduleHandler(w http.ResponseWriter, r *http.Request) { +// role := r.Context().Value("user_role").(int) +// userID := r.Context().Value("user_id").(int) + +// // TODO: Fetch schedule data from database +// scheduleData := map[string]interface{}{ +// "UserID": userID, +// "Schedule": []map[string]interface{}{ +// {"Date": "2025-08-26", "Time": "10:00 AM", "Task": "Door-to-door survey", "Location": "Downtown"}, +// {"Date": "2025-08-28", "Time": "2:00 PM", "Task": "Data entry", "Location": "Office"}, +// }, // Example: get from database +// } + +// data := createTemplateData("My Schedule", "schedual", role, true, scheduleData) +// utils.Render(w, "volunteer/schedule.html", data) +// } + +// Enhanced middleware to check JWT auth and add user context +func AuthMiddleware(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + cookie, err := r.Cookie("session") + if err != nil { + http.Redirect(w, r, "/login", http.StatusSeeOther) + return + } + + claims := &models.Claims{} + token, err := jwt.ParseWithClaims(cookie.Value, claims, func(token *jwt.Token) (interface{}, error) { + return jwtKey, nil + }) + + if err != nil || !token.Valid { + log.Printf("Invalid token: %v", err) + clearSessionCookie(w) // Clear invalid cookie + http.Redirect(w, r, "/login", http.StatusSeeOther) + return + } + + // Add user info to context + ctx := context.WithValue(r.Context(), "user_id", claims.UserID) + ctx = context.WithValue(ctx, "user_role", claims.Role) + r = r.WithContext(ctx) + + next.ServeHTTP(w, r) + } +} \ No newline at end of file diff --git a/app/internal/handlers/profile.go b/app/internal/handlers/profile.go new file mode 100644 index 0000000..29c8418 --- /dev/null +++ b/app/internal/handlers/profile.go @@ -0,0 +1,98 @@ +package handlers + +import ( + "log" + "net/http" + + "github.com/patel-mann/poll-system/app/internal/models" + "github.com/patel-mann/poll-system/app/internal/utils" +) + +func ProfileHandler(w http.ResponseWriter, r *http.Request) { + // Extract current user ID from session/jwt + currentUserID := r.Context().Value("user_id").(int) + + var user models.User + err := models.DB.QueryRow(` + SELECT user_id, first_name, last_name, email, phone, role_id, created_at, updated_at + FROM "users" + WHERE user_id = $1 + `, currentUserID).Scan( + &user.UserID, + &user.FirstName, + &user.LastName, + &user.Email, + &user.Phone, + &user.RoleID, + &user.CreatedAt, + &user.UpdatedAt, + ) + if err != nil { + log.Println("Profile query error:", err) + http.Error(w, "Could not load profile", http.StatusInternalServerError) + return + } + + role := r.Context().Value("user_role").(int) + adminnav := false + volunteernav := false + + if role == 1{ + adminnav = true + volunteernav = false + }else{ + volunteernav = true + adminnav = false + } + + utils.Render(w, "profile/profile.html", map[string]interface{}{ + "Title": "My Profile", + "IsAuthenticated": true, + "ShowAdminNav": adminnav, + "ShowVolunteerNav": volunteernav, + "User": user, + "ActiveSection": "profile", + }) +} + +// ProfileUpdateHandler handles profile form submissions +func ProfileUpdateHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Redirect(w, r, "/profile", http.StatusSeeOther) + return + } + + // Extract current user ID from session/jwt + currentUserID := r.Context().Value("user_id").(int) + + // Parse form values + err := r.ParseForm() + if err != nil { + log.Println("Form parse error:", err) + http.Error(w, "Invalid form submission", http.StatusBadRequest) + return + } + + firstName := r.FormValue("first_name") + lastName := r.FormValue("last_name") + phone := r.FormValue("phone") + + // Update in DB + _, err = models.DB.Exec(` + UPDATE "users" + SET first_name = $1, + last_name = $2, + phone = $3, + updated_at = NOW() + WHERE user_id = $4 + `, firstName, lastName, phone, currentUserID) + + if err != nil { + log.Println("Profile update error:", err) + http.Error(w, "Could not update profile", http.StatusInternalServerError) + return + } + + // Redirect back to profile with success + http.Redirect(w, r, "/profile?success=1", http.StatusSeeOther) +} diff --git a/app/internal/handlers/volunteer_posts.go b/app/internal/handlers/volunteer_posts.go new file mode 100644 index 0000000..3472859 --- /dev/null +++ b/app/internal/handlers/volunteer_posts.go @@ -0,0 +1,74 @@ +// Add this to your handlers package (create volunteer_posts.go or add to existing file) + +package handlers + +import ( + "fmt" + "net/http" + "strconv" + "time" + + "github.com/patel-mann/poll-system/app/internal/models" + "github.com/patel-mann/poll-system/app/internal/utils" +) + +// VolunteerPostsHandler - Read-only posts view for volunteers +func VolunteerPostsHandler(w http.ResponseWriter, r *http.Request) { + // Only allow GET requests for volunteers + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + // Get user info from context + role := r.Context().Value("user_role").(int) + + // Fetch posts from database + rows, err := models.DB.Query(` + SELECT p.post_id, p.author_id, u.first_name || ' ' || u.last_name AS author_name, + p.content, COALESCE(p.image_url, '') as image_url, p.created_at + FROM post p + JOIN users u ON p.author_id = u.user_id + ORDER BY p.created_at DESC + `) + if err != nil { + fmt.Printf("Database query error: %v\n", err) + http.Error(w, "Failed to fetch posts", http.StatusInternalServerError) + return + } + defer rows.Close() + + var posts []models.Post + for rows.Next() { + var p models.Post + err := rows.Scan(&p.PostID, &p.AuthorID, &p.AuthorName, &p.Content, &p.ImageURL, &p.CreatedAt) + if err != nil { + fmt.Printf("Row scan error: %v\n", err) + continue + } + posts = append(posts, p) + } + + // Add cache busting parameter to image URLs + for i := range posts { + if posts[i].ImageURL != "" { + posts[i].ImageURL += "?t=" + strconv.FormatInt(time.Now().UnixNano(), 10) + } + } + + // Get navigation flags + showAdminNav, showVolunteerNav := getNavFlags(role) + + fmt.Printf("Volunteer viewing %d posts\n", len(posts)) + + utils.Render(w, "dashboard/volunteer_dashboard.html", map[string]interface{}{ + "Title": "Community Posts", + "IsAuthenticated": true, + "ShowAdminNav": showAdminNav, + "ShowVolunteerNav": showVolunteerNav, + "Posts": posts, + "ActiveSection": "posts", + "IsVolunteer": true, // Flag to indicate this is volunteer view + }) +} + diff --git a/app/internal/models/db.go b/app/internal/models/db.go new file mode 100644 index 0000000..e1fc40a --- /dev/null +++ b/app/internal/models/db.go @@ -0,0 +1,31 @@ +package models + +import ( + "database/sql" + "fmt" + "log" + + _ "github.com/lib/pq" +) + +var DB *sql.DB + +func InitDB() { + var err error + + // Example DSN format for PostgreSQL: + // "postgres://username:password@host:port/dbname?sslmode=disable" + dsn := "postgres://mannpatel:Admin@localhost:5432/poll_database?sslmode=disable" + + DB, err = sql.Open("postgres", dsn) + if err != nil { + log.Fatalf("Failed to connect to DB: %v", err) + } + + err = DB.Ping() + if err != nil { + log.Fatalf("Failed to ping DB: %v", err) + } + + fmt.Println("Database connection successful") +} diff --git a/app/internal/models/structs.go b/app/internal/models/structs.go new file mode 100644 index 0000000..6be0847 --- /dev/null +++ b/app/internal/models/structs.go @@ -0,0 +1,176 @@ +package models + +import ( + "time" + + "github.com/golang-jwt/jwt/v5" +) + + +type Claims struct { + UserID int + Role int + jwt.RegisteredClaims +} + +type TokenResponse struct { + Token string + User User +} + +type ErrorResponse struct { + Error string + Details []string +} + +type Role struct { + RoleID int + Name string + CreatedAt time.Time + UpdatedAt time.Time +} + +type User struct { + UserID int + FirstName string + LastName string + Email string + Phone string + Password string + RoleID int + CreatedAt time.Time + UpdatedAt time.Time +} + +type UserAddress struct { + UserID int + AddressLine1 string + AddressLine2 string + City string + Province string + Country string + PostalCode string + CreatedAt time.Time + UpdatedAt time.Time +} + +// ===================== +// Address Database +// ===================== + +type AddressDatabase struct { + AddressID int + Address string + StreetName string + StreetType string + StreetQuadrant string + HouseNumber string + HouseAlpha *string + Longitude float64 + Latitude float64 + VisitedValidated bool + CreatedAt time.Time + UpdatedAt time.Time +} + +// ===================== +// Teams & Assignments +// ===================== + + +type Team struct { + TeamID int + TeamLeadID int + VolunteerID int + CreatedAt time.Time + UpdatedAt time.Time +} + +type AdminVolunteer struct { + AdminID int + VolunteerID int + IsActive bool + CreatedAt time.Time + UpdatedAt time.Time +} + +type Appointment struct { + SchedID int + UserID int + AddressID int + AppointmentDate time.Time + AppointmentTime time.Time + CreatedAt time.Time + UpdatedAt time.Time +} + +// ===================== +// Polls & Responses +// ===================== + +type Poll struct { + PollID int + AddressID int + UserID int + ResponseURL string + AmountDonated float64 + CreatedAt time.Time + UpdatedAt time.Time +} + +type PollResponse struct { + ResponseID int + PollID int + Signage bool + VotingChoice string + DonationAmount float64 + CreatedAt time.Time +} + +// ===================== +// Updates & Reactions +// ===================== + +type Post struct { + PostID int + AuthorID int + AuthorName string // for display + Content string + ImageURL string + CreatedAt time.Time +} + + +type Reaction struct { + ReactionID int + PostID int + UserID int + ReactionType string + CreatedAt time.Time +} + +// ===================== +// Volunteer Availability +// ===================== + +type Availability struct { + AvailabilityID int + UserID int + DayOfWeek string + StartTime time.Time + EndTime time.Time + CreatedAt time.Time +} + +// ===================== +// Chat Links +// ===================== + +type ChatLink struct { + ChatID int + Platform string + URL string + UserID *int + TeamID *int + CreatedAt time.Time +} diff --git a/app/internal/models/token.go b/app/internal/models/token.go new file mode 100644 index 0000000..d398833 --- /dev/null +++ b/app/internal/models/token.go @@ -0,0 +1,28 @@ +package models + +import ( + "fmt" + + "github.com/golang-jwt/jwt/v5" +) + +var jwtKey = []byte("your-secret-key") //TODO: Move to env/config + + +func ExtractClaims(tokenStr string) (*Claims, error) { + claims := &Claims{} + + token, err := jwt.ParseWithClaims(tokenStr, claims, func(token *jwt.Token) (interface{}, error) { + return jwtKey, nil + }) + + if err != nil { + return nil, err + } + + if !token.Valid { + return nil, fmt.Errorf("invalid token") + } + + return claims, nil +} \ No newline at end of file diff --git a/app/internal/templates/address/address.html b/app/internal/templates/address/address.html new file mode 100644 index 0000000..b0fe18e --- /dev/null +++ b/app/internal/templates/address/address.html @@ -0,0 +1,201 @@ +{{ define "content" }} + +
| ID | +Address | +Street | +House # | +Longitude | +Latitude | +Validated | +
|---|---|---|---|---|---|---|
| {{ .AddressID }} | +{{ .Address }} | ++ {{ .StreetName }} {{ .StreetType }} {{ .StreetQuadrant }} + | +{{ .HouseNumber }} | +{{ .Longitude }} | +{{ .Latitude }} | ++ {{ if .VisitedValidated }} + + Valid + + {{ else }} + + Invalid + + {{ end }} + | +
| + No addresses found + | +||||||
+ Active Volunteers +
++ {{.VolunteerCount}} +
++ Addresses Visited +
++ {{.ValidatedCount}} +
+Donation
++ ${{.TotalDonations}} +
++ Houses Left +
++ {{.HousesLeftPercent}}% +
+{{.AuthorName}}
++ {{.CreatedAt.Format "Jan 2, 2006"}} +
++ {{.AuthorName}} {{.Content}} +
++ Be the first to share something with the community! +
++ Manage volunteers, organize addresses, and track progress with our comprehensive polling system. +
+Everything you need to manage your polling operations efficiently and effectively.
+Organize and coordinate your volunteer teams efficiently with role-based access and scheduling.
+Keep track of all polling locations and assignments with real-time updates and mapping.
+Monitor progress with comprehensive analytics and detailed reporting dashboards.
++ Poll System was created to simplify and streamline the complex process of managing polling operations. + Our platform brings together volunteers, administrators, and team leaders in one unified system. +
++ With years of experience in civic technology, we understand the challenges faced by polling organizations. + Our solution provides the tools needed to coordinate effectively and ensure smooth operations. +
++ Join hundreds of organizations already using Poll System to manage their operations efficiently. +
+{{.AuthorName}}
++ {{.CreatedAt.Format "Jan 2, 2006"}} +
++ {{.AuthorName}} {{.Content}} +
++ Be the first to share something with the community! +
+{{ .User.Email }}
++ Active Locations +
+24
+Total Visitors
+12,847
+Revenue
+$47,392
+Conversion Rate
+3.2%
+No volunteers assigned yet.
+ {{end}} +|
+
+ ID
+
+ |
+
+
+ First Name
+
+ |
+
+
+ Last Name
+
+ |
+
+
+ Email
+
+ |
+
+
+ Phone
+
+ |
+
+
+ Role
+
+ |
+ Actions | +
|---|---|---|---|---|---|---|
| + | + | + | + | + | + + | +
+
+ Edit
+
+
+ |
+
No volunteers found
++ Try adjusting your search or filter criteria +
+