Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@
.env
.unknown

# gitgore the compiled file
*.exe

# gitgore the storage directory
storage/*
!storage/.gitkeep
storage/**/*
Expand Down
1 change: 1 addition & 0 deletions backend/cmd/app/config.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{}
4 changes: 4 additions & 0 deletions backend/cmd/app/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,8 +88,12 @@ func main() {
mux.HandleFunc("POST /v1/forgot-password/verify-otp", authHandler.ForgotPasswordVerifyOTP)
mux.HandleFunc("POST /v1/reset-password", authHandler.ResetPassword)
mux.HandleFunc("GET /u/{username}", userHandler.ProfileView)
mux.HandleFunc("PATCH /u/{username}/profile/edit", userHandler.ProfileEdit)
mux.HandleFunc("POST /v1/user/{username}/profile/verify-password", userHandler.ProfileVerifyPassword)
mux.HandleFunc("PUT /u/{username}/profile/password", userHandler.ProfileChangePassword)
mux.HandleFunc("GET /u/{username}/dashboard", userHandler.DashboardView)
mux.HandleFunc("GET /u/{username}/card", userHandler.CardView)
mux.HandleFunc("GET /u/{username}/settings", userHandler.SettingsView)
mux.HandleFunc("GET /u/{username}/topup", userHandler.TopUpView)
// Your frontend calls this to get the Xendit URL
mux.HandleFunc("POST /api/topup/create-session/{username}", userHandler.CreateXenditInvoice)
Expand Down
247 changes: 247 additions & 0 deletions backend/internal/user/customer_profile.go
Original file line number Diff line number Diff line change
@@ -1,19 +1,266 @@
package user

import (
"context"
"encoding/json"
"errors"
"fmt"
"log"
"net/http"
"strings"
"unicard-go/backend/internal/pkg/account"
jsonwrite "unicard-go/backend/internal/pkg/handler"

"golang.org/x/crypto/bcrypt"
)

// Sentinel errors for password verification
var (
ErrPasswordLookupFailed = errors.New("Failed to look up password hash")
ErrIncorrectPassword = errors.New("Incorrect password")
)

// ProfileUpdateRequest represents the expected payload for updating user profile information
type ProfileUpdateRequest struct {
Username string `json:"username,omitempty" db:"username"`
FullName string `json:"full_name,omitempty" db:"name"`
Email string `json:"email,omitempty" db:"email"`
Phone string `json:"phone_number,omitempty" db:"phone_number"`
CurrentPassword string `json:"current_password,omitempty"`
NewPassword string `json:"new_password,omitempty"`
ConfirmPassword string `json:"confirm_password,omitempty"`
}

// verifyCurrentPassword checks the given password against the stored hash for username.
// On success, it returns the stored hash so callers can run further checks
// (e.g. preventing the new password from being the same as the current one).
func (h *Handler) verifyCurrentPassword(ctx context.Context, username, password string) (string, error) {
var currentHash string
err := h.DB.QueryRowContext(ctx, "SELECT password_hash FROM users WHERE username = ?", username).Scan(&currentHash)
if err != nil {
return "", fmt.Errorf("%w: %v", ErrPasswordLookupFailed, err)
}

if err := bcrypt.CompareHashAndPassword([]byte(currentHash), []byte(password)); err != nil {
return "", ErrIncorrectPassword
}

return currentHash, nil
}

// ProfileView handles the display of the user's profile page
// It retrieves the username from the URL and renders the profile template
func (h *Handler) ProfileView(w http.ResponseWriter, r *http.Request) {
fmt.Println("Profile view is running...")

// Extract the username from the URL path
username := r.PathValue("username")
data := struct {
Username string
}{
Username: username,
}

// Render the profile template with the username data
h.Tpl.ExecuteTemplate(w, "profile.html", data)
}

// ProfileEdit renders the profile edit page (assumes router already filtered by method)
func (h *Handler) ProfileEdit(w http.ResponseWriter, r *http.Request) {
username := r.PathValue("username")
ctx := r.Context()

var req ProfileUpdateRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonwrite.WriteJSON(w, http.StatusBadRequest, jsonwrite.APIResponse{
Success: false,
Message: "Invalid request payload",
})
return
}

// PATCH — at least one field required
if req.FullName == "" && req.Email == "" && req.Phone == "" {
jsonwrite.WriteJSON(w, http.StatusBadRequest, jsonwrite.APIResponse{
Success: false,
Message: "At least one field is required",
})
return
}

// Build query dynamically based on what was sent
fields := []string{}
args := []any{}

if req.FullName != "" {
fields = append(fields, "name = ?")
args = append(args, req.FullName)
}
if req.Email != "" {
fields = append(fields, "email = ?")
args = append(args, req.Email)
}
if req.Phone != "" {
fields = append(fields, "phone_number = ?")
args = append(args, req.Phone)
}

// Append username as last arg for WHERE clause
args = append(args, username)

query := "UPDATE users SET " + strings.Join(fields, ", ") + " WHERE username = ?"

_, err := h.DB.ExecContext(ctx, query, args...)
if err != nil {
log.Printf("ProfileEdit DB error: %v | query: %s | args: %v", err, query, args)
jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{
Success: false,
Message: "Failed to update profile",
})
return
}

jsonwrite.WriteJSON(w, http.StatusOK, jsonwrite.APIResponse{
Success: true,
Message: "Profile updated successfully",
})
}

// ProfileVerifyPassword checks if the provided current password is correct
func (h *Handler) ProfileVerifyPassword(w http.ResponseWriter, r *http.Request) {
username := r.PathValue("username")
ctx := r.Context()

var req struct {
CurrentPassword string `json:"current_password"`
}

if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonwrite.WriteJSON(w, http.StatusBadRequest, jsonwrite.APIResponse{
Success: false,
Message: "Invalid request payload",
})
return
}

if req.CurrentPassword == "" {
jsonwrite.WriteJSON(w, http.StatusBadRequest, jsonwrite.APIResponse{
Success: false,
Message: "Current password is required",
})
return
}

_, err := h.verifyCurrentPassword(ctx, username, req.CurrentPassword)
switch {
case errors.Is(err, ErrPasswordLookupFailed):
log.Printf("ProfileVerifyPassword lookup error: %v", err)
jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{
Success: false,
Message: "Failed to verify current password",
})
return
case errors.Is(err, ErrIncorrectPassword):
jsonwrite.WriteJSON(w, http.StatusUnauthorized, jsonwrite.APIResponse{
Success: false,
Message: "Current password is incorrect",
})
return
}

jsonwrite.WriteJSON(w, http.StatusOK, jsonwrite.APIResponse{
Success: true,
Message: "Password verified",
})
}

// ProfileChangePassword handles changing the user's password
func (h *Handler) ProfileChangePassword(w http.ResponseWriter, r *http.Request) {
username := r.PathValue("username")

var req ProfileUpdateRequest
var ctx = r.Context()

if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonwrite.WriteJSON(w, http.StatusBadRequest, jsonwrite.APIResponse{
Success: false,
Message: "Invalid request payload",
})
return
}

if req.CurrentPassword == "" || req.NewPassword == "" || req.ConfirmPassword == "" {
log.Printf("One or more password fields are empty for user: %s", username)
jsonwrite.WriteJSON(w, http.StatusBadRequest, jsonwrite.APIResponse{
Success: false,
Message: "All password fields are required",
})
return
}

if req.NewPassword != req.ConfirmPassword {
log.Printf("New password and confirm password do not match for user: %s", username)
jsonwrite.WriteJSON(w, http.StatusBadRequest, jsonwrite.APIResponse{
Success: false,
Message: "Password do not match",
})
return
}

// Verify current password
log.Printf("Verifying current password for user: %s", username)
currentHash, err := h.verifyCurrentPassword(ctx, username, req.CurrentPassword)
switch {
case errors.Is(err, ErrPasswordLookupFailed):
log.Printf("Error fetching current password hash: %v", err)
jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{
Success: false,
Message: "Failed to verify current password",
})
return
case errors.Is(err, ErrIncorrectPassword):
log.Printf("Current password verification failed for user: %s", username)
jsonwrite.WriteJSON(w, http.StatusUnauthorized, jsonwrite.APIResponse{
Success: false,
Message: "Current password is incorrect",
})
return
}
log.Printf("Current password verified for user: %s", username)

// Prevent setting the same password again
if err := bcrypt.CompareHashAndPassword([]byte(currentHash), []byte(req.NewPassword)); err == nil {
jsonwrite.WriteJSON(w, http.StatusBadRequest, jsonwrite.APIResponse{
Success: false,
Message: "New password must be different from current password",
})
return
}

// Hash the new password using the account package's HashPassword function
hashedPassword, err := account.HashPassword(req.NewPassword)
if err != nil {
jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{
Success: false,
Message: "Failed to hash password",
})
return
}

// Save new password hash
query := "UPDATE users SET password_hash = ? WHERE username = ?"
_, err = h.DB.ExecContext(ctx, query, hashedPassword, username)
if err != nil {
jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{
Success: false,
Message: "Failed to update password",
})
return
}

// Respond with success message
jsonwrite.WriteJSON(w, http.StatusOK, jsonwrite.APIResponse{
Success: true,
Message: "Password updated successfully",
})
}
20 changes: 20 additions & 0 deletions backend/internal/user/settings.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package user

import (
"fmt"
"net/http"
)

// SettingsView handles the display of the user's settings page
func (h *Handler) SettingsView(w http.ResponseWriter, r *http.Request) {
fmt.Println("Settings view is running...")

username := r.PathValue("username")
data := struct {
Username string
}{
Username: username,
}

h.Tpl.ExecuteTemplate(w, "settings.html", data)
}
5 changes: 2 additions & 3 deletions docs/unicardv3.sql
Original file line number Diff line number Diff line change
Expand Up @@ -129,11 +129,10 @@ CREATE TABLE top_ups (
gateway_cost DECIMAL(10, 2) DEFAULT 0.00 COMMENT 'Actual fee incurred from external payment providers (GCash, Maya, Bank) per transaction',
net_gateway_fee DECIMAL(10, 2) GENERATED ALWAYS AS (convenience_fee - gateway_cost) STORED COMMENT 'Automatically calculated net revenue kept by the platform after 3rd party costs',
total_charged DECIMAL(10, 2) GENERATED ALWAYS AS (amount + convenience_fee) STORED COMMENT 'Automatically calculated column representing the absolute total cash value collected from the external channel source',
payment_method ENUM('cash', 'gcash', 'maya', 'over_the_counter', 'stripe') NOT NULL COMMENT 'Drives system tracking to audit cash-drawer liquid positions against programmatic API callbacks',
payment_method ENUM('cash', 'gcash', 'maya', 'over_the_counter', 'xendit') NOT NULL COMMENT 'Drives system tracking to audit cash-drawer liquid positions against programmatic API callbacks',
handled_by VARCHAR(50) NULL COMMENT 'Public user_id string identifier referencing the administrative staff member who manually accepted physical bills if OTC cash-loaded',
status ENUM('pending', 'completed', 'failed') DEFAULT 'completed' COMMENT 'State pipeline tracker handling payment gateway processing exceptions or drops',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT 'Auto-generated clock timestamp mapping exactly when wallet balance credits were finalized',
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 'Tracks chronological life cycle changes, such as a top-up shifting from pending to completed',
FOREIGN KEY (card_number) REFERENCES cards(card_number),
FOREIGN KEY (handled_by) REFERENCES users(user_id)
FOREIGN KEY (card_number) REFERENCES cards(card_number)
) COMMENT='High-growth balance loader ledger maintaining immutable compliance auditing for all incoming ecosystem liquidity channels';
Loading