From efc95d48c4473c0b32a3a0af885c91be48f12fce Mon Sep 17 00:00:00 2001 From: shop Date: Thu, 25 Jun 2026 17:07:02 +0300 Subject: [PATCH] Add user auth, cart, checkout and personal account for guests and registered users --- cmd/shop/main.go | 45 ++- docker-compose.yml | 1 + internal/auth/session.go | 102 +++-- internal/database/migrate.go | 44 +++ internal/handlers/customer.go | 377 +++++++++++++++++++ internal/handlers/home.go | 77 +--- internal/handlers/static/css/shop.css | 263 +++++++++++++ internal/handlers/templates/auth.html | 57 +++ internal/handlers/templates/home.html | 62 ++- internal/handlers/templates/shop_header.html | 26 ++ internal/handlers/templates/shop_pages.html | 187 +++++++++ internal/models/user.go | 50 +++ internal/repository/cart.go | 176 +++++++++ internal/repository/order.go | 93 +++++ internal/repository/user.go | 75 ++++ migrations/004_users_cart_orders.sql | 52 +++ 16 files changed, 1528 insertions(+), 159 deletions(-) create mode 100644 internal/handlers/customer.go create mode 100644 internal/handlers/static/css/shop.css create mode 100644 internal/handlers/templates/auth.html create mode 100644 internal/handlers/templates/shop_header.html create mode 100644 internal/handlers/templates/shop_pages.html create mode 100644 internal/models/user.go create mode 100644 internal/repository/cart.go create mode 100644 internal/repository/order.go create mode 100644 internal/repository/user.go create mode 100644 migrations/004_users_cart_orders.sql diff --git a/cmd/shop/main.go b/cmd/shop/main.go index 72e6fcb..0fbec0f 100644 --- a/cmd/shop/main.go +++ b/cmd/shop/main.go @@ -35,6 +35,9 @@ func main() { productRepo := repository.NewProductRepository(pool) adminRepo := repository.NewAdminRepository(pool) statsRepo := repository.NewStatsRepository(pool) + userRepo := repository.NewUserRepository(pool) + cartRepo := repository.NewCartRepository(pool) + orderRepo := repository.NewOrderRepository(pool) storage, err := upload.NewStorage(cfg.UploadDir) if err != nil { @@ -46,29 +49,47 @@ func main() { log.Fatalf("admin seed: %v", err) } log.Printf("admin ready: %s", cfg.AdminEmail) - } else { - log.Println("warning: set ADMIN_EMAIL and ADMIN_PASSWORD in .env to create admin") } - home, err := handlers.NewHomeHandler(productRepo, statsRepo) + tmpl, err := handlers.ParseTemplates() if err != nil { - log.Fatalf("handler: %v", err) + log.Fatalf("templates: %v", err) } - session := auth.NewSession(cfg.SessionSecret) - admin := handlers.NewAdminHandler(adminRepo, productRepo, statsRepo, storage, session, home.Templates()) + adminSession := auth.NewSession(cfg.SessionSecret, "shop_admin_session", "/admin") + userSession := auth.NewSession(cfg.SessionSecret, "shop_user_session", "/") + cartSession := auth.NewSession(cfg.SessionSecret, "shop_cart_key", "/") + + customer := handlers.NewCustomerHandler( + productRepo, userRepo, cartRepo, orderRepo, statsRepo, + userSession, cartSession, tmpl, + ) + admin := handlers.NewAdminHandler(adminRepo, productRepo, statsRepo, storage, adminSession, tmpl) mux := http.NewServeMux() - mux.Handle("GET /static/", http.StripPrefix("/static/", home.Static())) + mux.Handle("GET /static/", http.StripPrefix("/static/", handlers.StaticHandler())) mux.Handle("GET /uploads/", storage.FileServer()) - mux.HandleFunc("GET /health", home.Health) - mux.HandleFunc("GET /{$}", home.Home) + mux.HandleFunc("GET /health", customer.Health) + + mux.HandleFunc("GET /{$}", customer.Home) + mux.HandleFunc("GET /register", customer.RegisterPage) + mux.HandleFunc("POST /register", customer.Register) + mux.HandleFunc("GET /login", customer.LoginPage) + mux.HandleFunc("POST /login", customer.Login) + mux.HandleFunc("POST /logout", customer.Logout) + mux.HandleFunc("GET /cart", customer.CartPage) + mux.HandleFunc("POST /cart/add", customer.CartAdd) + mux.HandleFunc("POST /cart/update", customer.CartUpdate) + mux.HandleFunc("POST /cart/remove", customer.CartRemove) + mux.HandleFunc("GET /checkout", customer.CheckoutPage) + mux.HandleFunc("POST /checkout", customer.Checkout) + mux.HandleFunc("GET /account", customer.Account) + mux.HandleFunc("POST /account", customer.Account) mux.HandleFunc("GET /admin/login", admin.LoginPage) mux.HandleFunc("POST /admin/login", admin.Login) mux.HandleFunc("POST /admin/logout", admin.Logout) mux.HandleFunc("GET /admin/{$}", admin.Dashboard) - mux.HandleFunc("GET /admin/products", admin.ProductList) mux.HandleFunc("GET /admin/products/new", admin.ProductNew) mux.HandleFunc("POST /admin/products/save", admin.ProductSave) @@ -98,9 +119,7 @@ func main() { shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() - if err := srv.Shutdown(shutdownCtx); err != nil { - log.Printf("shutdown: %v", err) - } + _ = srv.Shutdown(shutdownCtx) } func getEnv(key, fallback string) string { diff --git a/docker-compose.yml b/docker-compose.yml index abd6ebd..9be6301 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,6 +13,7 @@ services: - ./migrations/001_init.sql:/docker-entrypoint-initdb.d/001_init.sql:ro - ./migrations/002_admins.sql:/docker-entrypoint-initdb.d/002_admins.sql:ro - ./migrations/003_admin_features.sql:/docker-entrypoint-initdb.d/003_admin_features.sql:ro + - ./migrations/004_users_cart_orders.sql:/docker-entrypoint-initdb.d/004_users_cart_orders.sql:ro healthcheck: test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-shop} -d ${DB_NAME:-shop}"] interval: 5s diff --git a/internal/auth/session.go b/internal/auth/session.go index c527a07..eca49bb 100644 --- a/internal/auth/session.go +++ b/internal/auth/session.go @@ -9,27 +9,33 @@ import ( "strconv" "strings" "time" + + "github.com/google/uuid" ) -const ( - cookieName = "shop_session" - sessionTTL = 24 * time.Hour -) +const defaultTTL = 7 * 24 * time.Hour type Session struct { - secret []byte + secret []byte + cookieName string + path string + ttl time.Duration } -func NewSession(secret string) *Session { - return &Session{secret: []byte(secret)} +func NewSession(secret, cookieName, path string) *Session { + return &Session{ + secret: []byte(secret), + cookieName: cookieName, + path: path, + ttl: defaultTTL, + } } -func (s *Session) Create(email string) (string, error) { - exp := time.Now().Add(sessionTTL).Unix() - payload := fmt.Sprintf("%s|%d", email, exp) +func (s *Session) Create(value string) (string, error) { + exp := time.Now().Add(s.ttl).Unix() + payload := fmt.Sprintf("%s|%d", value, exp) sig := s.sign(payload) - token := base64.URLEncoding.EncodeToString([]byte(payload + "|" + sig)) - return token, nil + return base64.URLEncoding.EncodeToString([]byte(payload + "|" + sig)), nil } func (s *Session) Validate(token string) (string, bool) { @@ -37,67 +43,93 @@ func (s *Session) Validate(token string) (string, bool) { if err != nil { return "", false } - parts := strings.Split(string(raw), "|") if len(parts) != 3 { return "", false } - - email, expStr, sig := parts[0], parts[1], parts[2] - payload := email + "|" + expStr - + value, expStr, sig := parts[0], parts[1], parts[2] + payload := value + "|" + expStr if !hmac.Equal([]byte(sig), []byte(s.sign(payload))) { return "", false } - exp, err := strconv.ParseInt(expStr, 10, 64) if err != nil || time.Now().Unix() > exp { return "", false } - - return email, true + return value, true } func (s *Session) SetCookie(w http.ResponseWriter, r *http.Request, token string) { http.SetCookie(w, &http.Cookie{ - Name: cookieName, + Name: s.cookieName, Value: token, - Path: "/admin", + Path: s.path, HttpOnly: true, - Secure: s.isSecure(r), + Secure: isSecure(r), SameSite: http.SameSiteLaxMode, - MaxAge: int(sessionTTL.Seconds()), + MaxAge: int(s.ttl.Seconds()), }) } func (s *Session) ClearCookie(w http.ResponseWriter, r *http.Request) { http.SetCookie(w, &http.Cookie{ - Name: cookieName, + Name: s.cookieName, Value: "", - Path: "/admin", + Path: s.path, HttpOnly: true, - Secure: s.isSecure(r), + Secure: isSecure(r), MaxAge: -1, }) } -func (s *Session) isSecure(r *http.Request) bool { - if r.TLS != nil { - return true - } - return strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https") -} - func (s *Session) FromRequest(r *http.Request) (string, bool) { - c, err := r.Cookie(cookieName) + c, err := r.Cookie(s.cookieName) if err != nil { return "", false } return s.Validate(c.Value) } +func (s *Session) EnsureKey(w http.ResponseWriter, r *http.Request) string { + if key, ok := s.FromRequest(r); ok && key != "" { + return key + } + key := uuid.New().String() + token, _ := s.Create(key) + s.SetCookie(w, r, token) + return key +} + func (s *Session) sign(payload string) string { mac := hmac.New(sha256.New, s.secret) mac.Write([]byte(payload)) return base64.URLEncoding.EncodeToString(mac.Sum(nil)) } + +func isSecure(r *http.Request) bool { + if r.TLS != nil { + return true + } + return strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https") +} + +func UserIDFromSession(s *Session, r *http.Request) (int, bool) { + val, ok := s.FromRequest(r) + if !ok { + return 0, false + } + if !strings.HasPrefix(val, "user:") { + return 0, false + } + id, err := strconv.Atoi(strings.TrimPrefix(val, "user:")) + return id, err == nil && id > 0 +} + +func SetUserSession(s *Session, w http.ResponseWriter, r *http.Request, userID int) error { + token, err := s.Create(fmt.Sprintf("user:%d", userID)) + if err != nil { + return err + } + s.SetCookie(w, r, token) + return nil +} diff --git a/internal/database/migrate.go b/internal/database/migrate.go index 0ca232b..8f7484a 100644 --- a/internal/database/migrate.go +++ b/internal/database/migrate.go @@ -33,6 +33,50 @@ func Migrate(ctx context.Context, pool *pgxpool.Pool) error { visit_date DATE NOT NULL DEFAULT CURRENT_DATE )`, `INSERT INTO site_stats (id) VALUES (1) ON CONFLICT (id) DO NOTHING`, + `CREATE TABLE IF NOT EXISTS users ( + id SERIAL PRIMARY KEY, + email VARCHAR(255) UNIQUE NOT NULL, + password_hash VARCHAR(255) NOT NULL, + name VARCHAR(255) NOT NULL DEFAULT '', + phone VARCHAR(50) NOT NULL DEFAULT '', + address TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + )`, + `CREATE TABLE IF NOT EXISTS carts ( + id SERIAL PRIMARY KEY, + user_id INT REFERENCES users(id) ON DELETE CASCADE, + session_key VARCHAR(64) NOT NULL DEFAULT '', + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + )`, + `CREATE UNIQUE INDEX IF NOT EXISTS idx_carts_user ON carts (user_id) WHERE user_id IS NOT NULL`, + `CREATE UNIQUE INDEX IF NOT EXISTS idx_carts_session ON carts (session_key) WHERE user_id IS NULL`, + `CREATE TABLE IF NOT EXISTS cart_items ( + id SERIAL PRIMARY KEY, + cart_id INT NOT NULL REFERENCES carts(id) ON DELETE CASCADE, + product_id INT NOT NULL REFERENCES products(id) ON DELETE CASCADE, + quantity INT NOT NULL DEFAULT 1 CHECK (quantity > 0), + UNIQUE (cart_id, product_id) + )`, + `CREATE TABLE IF NOT EXISTS orders ( + id SERIAL PRIMARY KEY, + user_id INT REFERENCES users(id) ON DELETE SET NULL, + guest_name VARCHAR(255) NOT NULL DEFAULT '', + guest_email VARCHAR(255) NOT NULL DEFAULT '', + guest_phone VARCHAR(50) NOT NULL DEFAULT '', + address TEXT NOT NULL DEFAULT '', + total NUMERIC(10,2) NOT NULL DEFAULT 0, + status VARCHAR(50) NOT NULL DEFAULT 'new', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + )`, + `CREATE TABLE IF NOT EXISTS order_items ( + id SERIAL PRIMARY KEY, + order_id INT NOT NULL REFERENCES orders(id) ON DELETE CASCADE, + product_id INT NOT NULL, + product_name VARCHAR(255) NOT NULL, + price NUMERIC(10,2) NOT NULL, + quantity INT NOT NULL CHECK (quantity > 0) + )`, + `CREATE INDEX IF NOT EXISTS idx_orders_user ON orders (user_id)`, } for _, q := range queries { diff --git a/internal/handlers/customer.go b/internal/handlers/customer.go new file mode 100644 index 0000000..067381e --- /dev/null +++ b/internal/handlers/customer.go @@ -0,0 +1,377 @@ +package handlers + +import ( + "html/template" + "log" + "net/http" + "strconv" + + "shop/internal/auth" + "shop/internal/models" + "shop/internal/repository" +) + +type CustomerHandler struct { + products *repository.ProductRepository + users *repository.UserRepository + cart *repository.CartRepository + orders *repository.OrderRepository + stats *repository.StatsRepository + userSession *auth.Session + cartSession *auth.Session + templates *template.Template +} + +type shopPage struct { + Title string + User *models.User + CartCount int + Products interface{} + Categories interface{} + Error string + Success string +} + +type cartPage struct { + shopPage + Items []models.CartItem + Total float64 +} + +type checkoutPage struct { + shopPage + Items []models.CartItem + Total float64 + Name string + Email string + Phone string + Address string +} + +type accountPage struct { + shopPage + Orders []models.Order + Name string + Phone string + Address string +} + +func NewCustomerHandler( + products *repository.ProductRepository, + users *repository.UserRepository, + cart *repository.CartRepository, + orders *repository.OrderRepository, + stats *repository.StatsRepository, + userSession, cartSession *auth.Session, + templates *template.Template, +) *CustomerHandler { + return &CustomerHandler{ + products: products, users: users, cart: cart, orders: orders, stats: stats, + userSession: userSession, cartSession: cartSession, templates: templates, + } +} + +func (h *CustomerHandler) render(w http.ResponseWriter, name string, data any) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := h.templates.ExecuteTemplate(w, name, data); err != nil { + log.Printf("render %s: %v", name, err) + } +} + +func (h *CustomerHandler) basePage(r *http.Request, w http.ResponseWriter, title string) shopPage { + p := shopPage{Title: title} + if uid, ok := auth.UserIDFromSession(h.userSession, r); ok { + if u, err := h.users.GetByID(r.Context(), uid); err == nil { + p.User = &u + } + } + cartID, _ := h.resolveCart(r, w) + if cartID > 0 { + if n, err := h.cart.ItemCount(r.Context(), cartID); err == nil { + p.CartCount = n + } + } + return p +} + +func (h *CustomerHandler) resolveCart(r *http.Request, w http.ResponseWriter) (int, error) { + ctx := r.Context() + sessionKey := h.cartSession.EnsureKey(w, r) + var userID *int + if uid, ok := auth.UserIDFromSession(h.userSession, r); ok { + userID = &uid + } + return h.cart.GetOrCreate(ctx, sessionKey, userID) +} + +func (h *CustomerHandler) Home(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + _ = h.stats.IncrementHomeVisit(ctx) + + products, _ := h.products.Featured(ctx, 8) + categories, _ := h.products.Categories(ctx) + + p := h.basePage(r, w, "Shop — Интернет-магазин") + p.Products = products + p.Categories = categories + h.render(w, "home.html", p) +} + +func (h *CustomerHandler) RegisterPage(w http.ResponseWriter, r *http.Request) { + if _, ok := auth.UserIDFromSession(h.userSession, r); ok { + http.Redirect(w, r, "/account", http.StatusSeeOther) + return + } + h.render(w, "register.html", h.basePage(r, w, "Регистрация")) +} + +func (h *CustomerHandler) Register(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, "Bad Request", http.StatusBadRequest) + return + } + ctx := r.Context() + email := r.FormValue("email") + password := r.FormValue("password") + name := r.FormValue("name") + + p := h.basePage(r, w, "Регистрация") + if email == "" || password == "" || name == "" { + p.Error = "Заполните все поля" + h.render(w, "register.html", p) + return + } + if len(password) < 6 { + p.Error = "Пароль минимум 6 символов" + h.render(w, "register.html", p) + return + } + exists, _ := h.users.EmailExists(ctx, email) + if exists { + p.Error = "Email уже зарегистрирован" + h.render(w, "register.html", p) + return + } + + u, err := h.users.Create(ctx, email, password, name) + if err != nil { + log.Printf("register: %v", err) + p.Error = "Ошибка регистрации" + h.render(w, "register.html", p) + return + } + + sessionKey := h.cartSession.EnsureKey(w, r) + _ = h.cart.MergeToUser(ctx, sessionKey, u.ID) + _ = auth.SetUserSession(h.userSession, w, r, u.ID) + http.Redirect(w, r, "/account", http.StatusSeeOther) +} + +func (h *CustomerHandler) LoginPage(w http.ResponseWriter, r *http.Request) { + if _, ok := auth.UserIDFromSession(h.userSession, r); ok { + http.Redirect(w, r, "/account", http.StatusSeeOther) + return + } + h.render(w, "login.html", h.basePage(r, w, "Вход")) +} + +func (h *CustomerHandler) Login(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, "Bad Request", http.StatusBadRequest) + return + } + ctx := r.Context() + u, err := h.users.Authenticate(ctx, r.FormValue("email"), r.FormValue("password")) + p := h.basePage(r, w, "Вход") + if err != nil { + p.Error = "Неверный email или пароль" + h.render(w, "login.html", p) + return + } + sessionKey := h.cartSession.EnsureKey(w, r) + _ = h.cart.MergeToUser(ctx, sessionKey, u.ID) + _ = auth.SetUserSession(h.userSession, w, r, u.ID) + http.Redirect(w, r, "/account", http.StatusSeeOther) +} + +func (h *CustomerHandler) Logout(w http.ResponseWriter, r *http.Request) { + h.userSession.ClearCookie(w, r) + http.Redirect(w, r, "/", http.StatusSeeOther) +} + +func (h *CustomerHandler) CartAdd(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, "Bad Request", http.StatusBadRequest) + return + } + productID, _ := strconv.Atoi(r.FormValue("product_id")) + qty, _ := strconv.Atoi(r.FormValue("quantity")) + if qty < 1 { + qty = 1 + } + if productID <= 0 { + http.Redirect(w, r, "/", http.StatusSeeOther) + return + } + cartID, err := h.resolveCart(r, w) + if err == nil { + _ = h.cart.AddItem(r.Context(), cartID, productID, qty) + } + http.Redirect(w, r, "/cart", http.StatusSeeOther) +} + +func (h *CustomerHandler) CartPage(w http.ResponseWriter, r *http.Request) { + cartID, err := h.resolveCart(r, w) + p := cartPage{shopPage: h.basePage(r, w, "Корзина")} + if err != nil || cartID == 0 { + h.render(w, "cart.html", p) + return + } + ctx := r.Context() + p.Items, _ = h.cart.Items(ctx, cartID) + p.Total, _ = h.cart.Total(ctx, cartID) + h.render(w, "cart.html", p) +} + +func (h *CustomerHandler) CartUpdate(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, "Bad Request", http.StatusBadRequest) + return + } + productID, _ := strconv.Atoi(r.FormValue("product_id")) + qty, _ := strconv.Atoi(r.FormValue("quantity")) + cartID, _ := h.resolveCart(r, w) + if cartID > 0 { + _ = h.cart.UpdateItem(r.Context(), cartID, productID, qty) + } + http.Redirect(w, r, "/cart", http.StatusSeeOther) +} + +func (h *CustomerHandler) CartRemove(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, "Bad Request", http.StatusBadRequest) + return + } + productID, _ := strconv.Atoi(r.FormValue("product_id")) + cartID, _ := h.resolveCart(r, w) + if cartID > 0 { + _ = h.cart.RemoveItem(r.Context(), cartID, productID) + } + http.Redirect(w, r, "/cart", http.StatusSeeOther) +} + +func (h *CustomerHandler) CheckoutPage(w http.ResponseWriter, r *http.Request) { + cartID, _ := h.resolveCart(r, w) + ctx := r.Context() + items, _ := h.cart.Items(ctx, cartID) + if len(items) == 0 { + http.Redirect(w, r, "/cart", http.StatusSeeOther) + return + } + total, _ := h.cart.Total(ctx, cartID) + p := checkoutPage{ + shopPage: h.basePage(r, w, "Оформление заказа"), + Items: items, + Total: total, + } + if p.User != nil { + p.Name = p.User.Name + p.Email = p.User.Email + p.Phone = p.User.Phone + p.Address = p.User.Address + } + h.render(w, "checkout.html", p) +} + +func (h *CustomerHandler) Checkout(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, "Bad Request", http.StatusBadRequest) + return + } + ctx := r.Context() + cartID, _ := h.resolveCart(r, w) + items, _ := h.cart.Items(ctx, cartID) + if len(items) == 0 { + http.Redirect(w, r, "/cart", http.StatusSeeOther) + return + } + total, _ := h.cart.Total(ctx, cartID) + + name := r.FormValue("name") + email := r.FormValue("email") + phone := r.FormValue("phone") + address := r.FormValue("address") + + p := checkoutPage{ + shopPage: h.basePage(r, w, "Оформление заказа"), + Items: items, Total: total, + Name: name, Email: email, Phone: phone, Address: address, + } + if name == "" || email == "" || phone == "" || address == "" { + p.Error = "Заполните все поля доставки" + h.render(w, "checkout.html", p) + return + } + + order := models.Order{ + GuestName: name, GuestEmail: email, GuestPhone: phone, + Address: address, Total: total, + } + if uid, ok := auth.UserIDFromSession(h.userSession, r); ok { + order.UserID = &uid + _ = h.users.UpdateProfile(ctx, &models.User{ID: uid, Name: name, Phone: phone, Address: address}) + } + + if err := h.orders.Create(ctx, &order, items); err != nil { + log.Printf("checkout: %v", err) + p.Error = "Ошибка оформления заказа" + h.render(w, "checkout.html", p) + return + } + _ = h.cart.Clear(ctx, cartID) + + success := h.basePage(r, w, "Заказ оформлен") + success.Success = "Заказ #" + strconv.Itoa(order.ID) + " успешно создан!" + h.render(w, "order_success.html", success) +} + +func (h *CustomerHandler) Account(w http.ResponseWriter, r *http.Request) { + uid, ok := auth.UserIDFromSession(h.userSession, r) + if !ok { + http.Redirect(w, r, "/login", http.StatusSeeOther) + return + } + ctx := r.Context() + u, err := h.users.GetByID(ctx, uid) + if err != nil { + http.Redirect(w, r, "/login", http.StatusSeeOther) + return + } + + if r.Method == http.MethodPost { + _ = r.ParseForm() + u.Name = r.FormValue("name") + u.Phone = r.FormValue("phone") + u.Address = r.FormValue("address") + _ = h.users.UpdateProfile(ctx, &u) + } + + orders, _ := h.orders.ByUser(ctx, uid) + p := accountPage{ + shopPage: h.basePage(r, w, "Личный кабинет"), + Orders: orders, + Name: u.Name, + Phone: u.Phone, + Address: u.Address, + } + p.User = &u + if r.Method == http.MethodPost { + p.Success = "Профиль сохранён" + } + h.render(w, "account.html", p) +} + +func (h *CustomerHandler) Health(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"status":"ok"}`)) +} diff --git a/internal/handlers/home.go b/internal/handlers/home.go index c825f09..ce76821 100644 --- a/internal/handlers/home.go +++ b/internal/handlers/home.go @@ -7,8 +7,6 @@ import ( "log" "net/http" "regexp" - - "shop/internal/repository" ) //go:embed templates/* @@ -17,86 +15,23 @@ var templateFS embed.FS //go:embed static/* var staticFS embed.FS -type HomeHandler struct { - repo *repository.ProductRepository - stats *repository.StatsRepository - templates *template.Template -} - -type homePageData struct { - Title string - Products interface{} - Categories interface{} -} - -func NewHomeHandler(repo *repository.ProductRepository, stats *repository.StatsRepository) (*HomeHandler, error) { - funcMap := template.FuncMap{ - "plain": stripHTML, - } - tmpl, err := template.New("").Funcs(funcMap).ParseFS(templateFS, "templates/*.html") - if err != nil { - return nil, err - } - return &HomeHandler{repo: repo, stats: stats, templates: tmpl}, nil -} - var tagRe = regexp.MustCompile(`<[^>]*>`) func stripHTML(s string) string { return tagRe.ReplaceAllString(s, "") } -func (h *HomeHandler) Templates() *template.Template { - return h.templates +func ParseTemplates() (*template.Template, error) { + funcMap := template.FuncMap{ + "plain": stripHTML, + } + return template.New("").Funcs(funcMap).ParseFS(templateFS, "templates/*.html") } -func (h *HomeHandler) Static() http.Handler { +func StaticHandler() http.Handler { sub, err := fs.Sub(staticFS, "static") if err != nil { log.Fatal(err) } return http.FileServer(http.FS(sub)) } - -func (h *HomeHandler) Home(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/" { - http.NotFound(w, r) - return - } - - ctx := r.Context() - - if err := h.stats.IncrementHomeVisit(ctx); err != nil { - log.Printf("visit counter: %v", err) - } - - products, err := h.repo.Featured(ctx, 8) - if err != nil { - log.Printf("featured products: %v", err) - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - return - } - - categories, err := h.repo.Categories(ctx) - if err != nil { - log.Printf("categories: %v", err) - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - return - } - - data := homePageData{ - Title: "Shop — Интернет-магазин", - Products: products, - Categories: categories, - } - - w.Header().Set("Content-Type", "text/html; charset=utf-8") - if err := h.templates.ExecuteTemplate(w, "home.html", data); err != nil { - log.Printf("render: %v", err) - } -} - -func (h *HomeHandler) Health(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - w.Write([]byte(`{"status":"ok"}`)) -} diff --git a/internal/handlers/static/css/shop.css b/internal/handlers/static/css/shop.css new file mode 100644 index 0000000..51362b1 --- /dev/null +++ b/internal/handlers/static/css/shop.css @@ -0,0 +1,263 @@ +.shop-page { + padding: 40px 24px 80px; + min-height: 60vh; +} + +.shop-card { + background: #fff; + border-radius: 16px; + padding: 32px; + border: 1px solid #e5e7eb; + max-width: 480px; + margin: 0 auto; +} + +.shop-card h1 { + font-size: 1.75rem; + margin-bottom: 24px; +} + +.shop-card--empty, +.shop-card--success { + text-align: center; +} + +.shop-card--success h1 { + color: #059669; +} + +.shop-form { + display: flex; + flex-direction: column; + gap: 16px; +} + +.shop-form label { + display: flex; + flex-direction: column; + gap: 6px; + font-size: 0.875rem; + font-weight: 500; +} + +.shop-form input, +.shop-form textarea { + padding: 12px 14px; + border: 1px solid #e5e7eb; + border-radius: 10px; + font-family: inherit; + font-size: 1rem; +} + +.shop-form input:focus, +.shop-form textarea:focus { + outline: none; + border-color: #4f46e5; +} + +.shop-alert { + padding: 12px 16px; + border-radius: 10px; + margin-bottom: 16px; + font-size: 0.9rem; +} + +.shop-alert--error { + background: #fef2f2; + color: #b91c1c; +} + +.shop-alert--ok { + background: #d1fae5; + color: #065f46; +} + +.shop-hint { + font-size: 0.875rem; + color: #6b7280; + margin-top: 16px; +} + +.shop-hint a { + color: #4f46e5; +} + +.shop-actions { + display: flex; + gap: 12px; + justify-content: center; + margin-top: 24px; + flex-wrap: wrap; +} + +.cart-layout, +.checkout-layout, +.account-layout { + display: grid; + gap: 24px; + margin-top: 24px; +} + +.cart-layout { + grid-template-columns: 1fr 300px; +} + +.checkout-layout { + grid-template-columns: 1fr 320px; +} + +.account-layout { + grid-template-columns: 1fr 1fr; + max-width: 1000px; +} + +.account-layout .shop-card { + max-width: none; + margin: 0; +} + +.account-header { + display: flex; + justify-content: space-between; + align-items: center; +} + +.cart-items { + display: flex; + flex-direction: column; + gap: 16px; +} + +.cart-item { + display: flex; + align-items: center; + gap: 16px; + background: #fff; + padding: 16px; + border-radius: 12px; + border: 1px solid #e5e7eb; +} + +.cart-item__img { + width: 72px; + height: 72px; + object-fit: cover; + border-radius: 8px; +} + +.cart-item__info { + flex: 1; +} + +.cart-item__info h3 { + font-size: 1rem; + margin-bottom: 4px; +} + +.cart-item__price { + color: #4f46e5; + font-weight: 600; +} + +.cart-item__qty { + display: flex; + gap: 8px; + align-items: center; +} + +.cart-item__qty input { + width: 60px; + padding: 8px; + border: 1px solid #e5e7eb; + border-radius: 8px; +} + +.cart-summary { + background: #fff; + padding: 24px; + border-radius: 16px; + border: 1px solid #e5e7eb; + height: fit-content; + position: sticky; + top: 88px; +} + +.cart-summary h2 { + font-size: 1.1rem; + margin-bottom: 16px; +} + +.cart-summary__total { + font-size: 1.5rem; + font-weight: 700; + margin: 16px 0; + color: #1a1a2e; +} + +.checkout-item { + display: flex; + justify-content: space-between; + padding: 8px 0; + border-bottom: 1px solid #f3f4f6; + font-size: 0.9rem; +} + +.order-card { + border: 1px solid #e5e7eb; + border-radius: 12px; + padding: 16px; + margin-bottom: 12px; +} + +.order-card__head { + display: flex; + gap: 12px; + align-items: center; + flex-wrap: wrap; + margin-bottom: 12px; + font-size: 0.9rem; +} + +.order-card__items { + list-style: none; + font-size: 0.875rem; + color: #6b7280; + margin-bottom: 8px; +} + +.order-card__total { + font-weight: 600; +} + +.order-card .admin-badge, +.order-badge { + display: inline-block; + padding: 4px 10px; + border-radius: 100px; + font-size: 0.75rem; + font-weight: 600; + background: #d1fae5; + color: #065f46; +} + +.btn--danger { + background: #fef2f2; + color: #b91c1c; + border: 1px solid #fecaca; +} + +.product-card__footer form { + margin: 0; +} + +@media (max-width: 768px) { + .cart-layout, + .checkout-layout, + .account-layout { + grid-template-columns: 1fr; + } + + .cart-item { + flex-wrap: wrap; + } +} diff --git a/internal/handlers/templates/auth.html b/internal/handlers/templates/auth.html new file mode 100644 index 0000000..1cec2de --- /dev/null +++ b/internal/handlers/templates/auth.html @@ -0,0 +1,57 @@ +{{define "register.html"}} + + + + + + {{.Title}} + + + + + {{template "shop_header" .}} +
+
+

Регистрация

+ {{if .Error}}
{{.Error}}
{{end}} +
+ + + + +
+

Уже есть аккаунт? Войти

+

Можно покупать и без регистрации

+
+
+ + +{{end}} + +{{define "login.html"}} + + + + + + {{.Title}} + + + + + {{template "shop_header" .}} +
+
+

Вход

+ {{if .Error}}
{{.Error}}
{{end}} +
+ + + +
+

Нет аккаунта? Регистрация

+
+
+ + +{{end}} diff --git a/internal/handlers/templates/home.html b/internal/handlers/templates/home.html index 399a950..d2e47dd 100644 --- a/internal/handlers/templates/home.html +++ b/internal/handlers/templates/home.html @@ -11,38 +11,17 @@ -
-
- - -
- - -
-
-
+ {{template "shop_header" .}}
Новая коллекция 2026

Всё лучшее —
в одном магазине

-

Электроника, одежда, дом и сад. Быстрая доставка по всей стране, гарантия качества и удобная оплата.

+

Электроника, одежда, дом и сад. Покупайте с регистрацией или без — как вам удобно.

@@ -51,8 +30,8 @@ от 3 000 ₽
- Товаров в каталоге - 10 000+ + Покупка без регистрации + Доступна
@@ -78,7 +57,6 @@

Популярные товары

- Весь каталог →
{{range .Products}} @@ -92,7 +70,11 @@

{{plain .Description}}

@@ -110,22 +92,22 @@
🚚

Быстрая доставка

-

Доставим заказ за 1–3 дня в любой город России

+

Доставим заказ за 1–3 дня

+
+
+
👤
+

Без регистрации

+

Оформите заказ как гость — достаточно email и телефона

🔒
-

Безопасная оплата

-

Карты, СБП и оплата при получении

-
-
-
↩️
-

Лёгкий возврат

-

14 дней на возврат без лишних вопросов

+

Личный кабинет

+

История заказов и сохранённые данные для постоянных клиентов

💬

Поддержка 24/7

-

Ответим на любой вопрос в чате или по телефону

+

Ответим на любой вопрос

@@ -139,9 +121,9 @@ diff --git a/internal/handlers/templates/shop_header.html b/internal/handlers/templates/shop_header.html new file mode 100644 index 0000000..a7edc4d --- /dev/null +++ b/internal/handlers/templates/shop_header.html @@ -0,0 +1,26 @@ +{{define "shop_header"}} +
+ +
+{{end}} diff --git a/internal/handlers/templates/shop_pages.html b/internal/handlers/templates/shop_pages.html new file mode 100644 index 0000000..913b533 --- /dev/null +++ b/internal/handlers/templates/shop_pages.html @@ -0,0 +1,187 @@ +{{define "cart.html"}} + + + + + + {{.Title}} + + + + + {{template "shop_header" .}} +
+

Корзина

+ {{if .Items}} +
+
+ {{range .Items}} +
+ {{.Product.Name}} +
+

{{.Product.Name}}

+

{{printf "%.0f" .Product.Price}} ₽

+
+
+ + + +
+
+ + +
+
+ {{end}} +
+ +
+ {{else}} +
+

Корзина пуста

+ В каталог +
+ {{end}} +
+ + +{{end}} + +{{define "checkout.html"}} + + + + + + {{.Title}} + + + + + {{template "shop_header" .}} +
+

Оформление заказа

+ {{if .Error}}
{{.Error}}
{{end}} +
+
+

Данные для доставки

+ {{if .User}} +

Вы вошли как {{.User.Email}}. Данные подставлены из профиля.

+ {{else}} +

Покупка без регистрации — укажите контакты для доставки.

+ {{end}} + + + + + +
+ +
+
+ + +{{end}} + +{{define "order_success.html"}} + + + + + + {{.Title}} + + + + + {{template "shop_header" .}} +
+
+

✓ Заказ оформлен!

+

{{.Success}}

+

Мы свяжемся с вами для подтверждения доставки.

+
+ На главную + {{if .User}}Мои заказы{{end}} +
+
+
+ + +{{end}} + +{{define "account.html"}} + + + + + + {{.Title}} + + + + + {{template "shop_header" .}} +
+ + + {{if .Success}}
{{.Success}}
{{end}} + + +
+ + +{{end}} diff --git a/internal/models/user.go b/internal/models/user.go new file mode 100644 index 0000000..e9b6d89 --- /dev/null +++ b/internal/models/user.go @@ -0,0 +1,50 @@ +package models + +import "time" + +type User struct { + ID int + Email string + Name string + Phone string + Address string + CreatedAt time.Time +} + +type Cart struct { + ID int + UserID *int + SessionKey string + Items []CartItem + UpdatedAt time.Time +} + +type CartItem struct { + ID int + CartID int + ProductID int + Quantity int + Product Product +} + +type Order struct { + ID int + UserID *int + GuestName string + GuestEmail string + GuestPhone string + Address string + Total float64 + Status string + CreatedAt time.Time + Items []OrderItem +} + +type OrderItem struct { + ID int + OrderID int + ProductID int + ProductName string + Price float64 + Quantity int +} diff --git a/internal/repository/cart.go b/internal/repository/cart.go new file mode 100644 index 0000000..6223e1c --- /dev/null +++ b/internal/repository/cart.go @@ -0,0 +1,176 @@ +package repository + +import ( + "context" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + + "shop/internal/models" +) + +type CartRepository struct { + pool *pgxpool.Pool +} + +func NewCartRepository(pool *pgxpool.Pool) *CartRepository { + return &CartRepository{pool: pool} +} + +func (r *CartRepository) GetOrCreate(ctx context.Context, sessionKey string, userID *int) (int, error) { + if userID != nil { + var cartID int + err := r.pool.QueryRow(ctx, `SELECT id FROM carts WHERE user_id = $1`, *userID).Scan(&cartID) + if err == nil { + return cartID, nil + } + if err != pgx.ErrNoRows { + return 0, err + } + err = r.pool.QueryRow(ctx, ` + INSERT INTO carts (user_id, session_key) VALUES ($1, $2) RETURNING id + `, *userID, sessionKey).Scan(&cartID) + return cartID, err + } + + var cartID int + err := r.pool.QueryRow(ctx, ` + SELECT id FROM carts WHERE session_key = $1 AND user_id IS NULL + `, sessionKey).Scan(&cartID) + if err == nil { + return cartID, nil + } + if err != pgx.ErrNoRows { + return 0, err + } + err = r.pool.QueryRow(ctx, ` + INSERT INTO carts (session_key) VALUES ($1) RETURNING id + `, sessionKey).Scan(&cartID) + return cartID, err +} + +func (r *CartRepository) MergeToUser(ctx context.Context, sessionKey string, userID int) error { + tx, err := r.pool.Begin(ctx) + if err != nil { + return err + } + defer tx.Rollback(ctx) + + var guestCartID int + err = tx.QueryRow(ctx, ` + SELECT id FROM carts WHERE session_key = $1 AND user_id IS NULL + `, sessionKey).Scan(&guestCartID) + if err == pgx.ErrNoRows { + return tx.Commit(ctx) + } + if err != nil { + return err + } + + var userCartID int + err = tx.QueryRow(ctx, `SELECT id FROM carts WHERE user_id = $1`, userID).Scan(&userCartID) + if err == pgx.ErrNoRows { + _, err = tx.Exec(ctx, `UPDATE carts SET user_id = $1 WHERE id = $2`, userID, guestCartID) + if err != nil { + return err + } + return tx.Commit(ctx) + } + if err != nil { + return err + } + + _, err = tx.Exec(ctx, ` + INSERT INTO cart_items (cart_id, product_id, quantity) + SELECT $1, product_id, quantity FROM cart_items WHERE cart_id = $2 + ON CONFLICT (cart_id, product_id) DO UPDATE + SET quantity = cart_items.quantity + EXCLUDED.quantity + `, userCartID, guestCartID) + if err != nil { + return err + } + _, err = tx.Exec(ctx, `DELETE FROM carts WHERE id = $1`, guestCartID) + if err != nil { + return err + } + return tx.Commit(ctx) +} + +func (r *CartRepository) AddItem(ctx context.Context, cartID, productID, qty int) error { + _, err := r.pool.Exec(ctx, ` + INSERT INTO cart_items (cart_id, product_id, quantity) + VALUES ($1, $2, $3) + ON CONFLICT (cart_id, product_id) DO UPDATE + SET quantity = cart_items.quantity + EXCLUDED.quantity + `, cartID, productID, qty) + return err +} + +func (r *CartRepository) UpdateItem(ctx context.Context, cartID, productID, qty int) error { + if qty <= 0 { + _, err := r.pool.Exec(ctx, `DELETE FROM cart_items WHERE cart_id = $1 AND product_id = $2`, cartID, productID) + return err + } + _, err := r.pool.Exec(ctx, ` + UPDATE cart_items SET quantity = $3 WHERE cart_id = $1 AND product_id = $2 + `, cartID, productID, qty) + return err +} + +func (r *CartRepository) RemoveItem(ctx context.Context, cartID, productID int) error { + _, err := r.pool.Exec(ctx, `DELETE FROM cart_items WHERE cart_id = $1 AND product_id = $2`, cartID, productID) + return err +} + +func (r *CartRepository) Clear(ctx context.Context, cartID int) error { + _, err := r.pool.Exec(ctx, `DELETE FROM cart_items WHERE cart_id = $1`, cartID) + return err +} + +func (r *CartRepository) ItemCount(ctx context.Context, cartID int) (int, error) { + var count int + err := r.pool.QueryRow(ctx, ` + SELECT COALESCE(SUM(quantity), 0) FROM cart_items WHERE cart_id = $1 + `, cartID).Scan(&count) + return count, err +} + +func (r *CartRepository) Items(ctx context.Context, cartID int) ([]models.CartItem, error) { + rows, err := r.pool.Query(ctx, ` + SELECT ci.id, ci.cart_id, ci.product_id, ci.quantity, + p.id, p.name, p.description, p.details, p.price, p.image_url, p.category, p.is_active, p.created_at, p.updated_at + FROM cart_items ci + JOIN products p ON p.id = ci.product_id + WHERE ci.cart_id = $1 AND p.is_active = true + ORDER BY ci.id + `, cartID) + if err != nil { + return nil, err + } + defer rows.Close() + + var items []models.CartItem + for rows.Next() { + var ci models.CartItem + var p models.Product + if err := rows.Scan( + &ci.ID, &ci.CartID, &ci.ProductID, &ci.Quantity, + &p.ID, &p.Name, &p.Description, &p.Details, &p.Price, &p.ImageURL, &p.Category, &p.IsActive, &p.CreatedAt, &p.UpdatedAt, + ); err != nil { + return nil, err + } + ci.Product = p + items = append(items, ci) + } + return items, rows.Err() +} + +func (r *CartRepository) Total(ctx context.Context, cartID int) (float64, error) { + var total float64 + err := r.pool.QueryRow(ctx, ` + SELECT COALESCE(SUM(ci.quantity * p.price), 0) + FROM cart_items ci JOIN products p ON p.id = ci.product_id + WHERE ci.cart_id = $1 + `, cartID).Scan(&total) + return total, err +} diff --git a/internal/repository/order.go b/internal/repository/order.go new file mode 100644 index 0000000..c811ac9 --- /dev/null +++ b/internal/repository/order.go @@ -0,0 +1,93 @@ +package repository + +import ( + "context" + + "github.com/jackc/pgx/v5/pgxpool" + + "shop/internal/models" +) + +type OrderRepository struct { + pool *pgxpool.Pool +} + +func NewOrderRepository(pool *pgxpool.Pool) *OrderRepository { + return &OrderRepository{pool: pool} +} + +func (r *OrderRepository) Create(ctx context.Context, order *models.Order, items []models.CartItem) error { + tx, err := r.pool.Begin(ctx) + if err != nil { + return err + } + defer tx.Rollback(ctx) + + err = tx.QueryRow(ctx, ` + INSERT INTO orders (user_id, guest_name, guest_email, guest_phone, address, total, status) + VALUES ($1, $2, $3, $4, $5, $6, 'new') + RETURNING id, created_at + `, order.UserID, order.GuestName, order.GuestEmail, order.GuestPhone, order.Address, order.Total, + ).Scan(&order.ID, &order.CreatedAt) + if err != nil { + return err + } + + for _, item := range items { + _, err = tx.Exec(ctx, ` + INSERT INTO order_items (order_id, product_id, product_name, price, quantity) + VALUES ($1, $2, $3, $4, $5) + `, order.ID, item.ProductID, item.Product.Name, item.Product.Price, item.Quantity) + if err != nil { + return err + } + } + + return tx.Commit(ctx) +} + +func (r *OrderRepository) ByUser(ctx context.Context, userID int) ([]models.Order, error) { + rows, err := r.pool.Query(ctx, ` + SELECT id, user_id, guest_name, guest_email, guest_phone, address, total, status, created_at + FROM orders WHERE user_id = $1 ORDER BY created_at DESC + `, userID) + if err != nil { + return nil, err + } + defer rows.Close() + + var orders []models.Order + for rows.Next() { + var o models.Order + if err := rows.Scan(&o.ID, &o.UserID, &o.GuestName, &o.GuestEmail, &o.GuestPhone, &o.Address, &o.Total, &o.Status, &o.CreatedAt); err != nil { + return nil, err + } + o.Items, err = r.items(ctx, o.ID) + if err != nil { + return nil, err + } + orders = append(orders, o) + } + return orders, rows.Err() +} + +func (r *OrderRepository) items(ctx context.Context, orderID int) ([]models.OrderItem, error) { + rows, err := r.pool.Query(ctx, ` + SELECT id, order_id, product_id, product_name, price, quantity + FROM order_items WHERE order_id = $1 + `, orderID) + if err != nil { + return nil, err + } + defer rows.Close() + + var items []models.OrderItem + for rows.Next() { + var i models.OrderItem + if err := rows.Scan(&i.ID, &i.OrderID, &i.ProductID, &i.ProductName, &i.Price, &i.Quantity); err != nil { + return nil, err + } + items = append(items, i) + } + return items, rows.Err() +} diff --git a/internal/repository/user.go b/internal/repository/user.go new file mode 100644 index 0000000..f1140d9 --- /dev/null +++ b/internal/repository/user.go @@ -0,0 +1,75 @@ +package repository + +import ( + "context" + "errors" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + "golang.org/x/crypto/bcrypt" + + "shop/internal/models" +) + +type UserRepository struct { + pool *pgxpool.Pool +} + +func NewUserRepository(pool *pgxpool.Pool) *UserRepository { + return &UserRepository{pool: pool} +} + +func (r *UserRepository) Create(ctx context.Context, email, password, name string) (models.User, error) { + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return models.User{}, err + } + + var u models.User + err = r.pool.QueryRow(ctx, ` + INSERT INTO users (email, password_hash, name) + VALUES ($1, $2, $3) + RETURNING id, email, name, phone, address, created_at + `, email, string(hash), name).Scan(&u.ID, &u.Email, &u.Name, &u.Phone, &u.Address, &u.CreatedAt) + return u, err +} + +func (r *UserRepository) Authenticate(ctx context.Context, email, password string) (models.User, error) { + var hash string + var u models.User + err := r.pool.QueryRow(ctx, ` + SELECT id, email, name, phone, address, created_at, password_hash + FROM users WHERE email = $1 + `, email).Scan(&u.ID, &u.Email, &u.Name, &u.Phone, &u.Address, &u.CreatedAt, &hash) + if errors.Is(err, pgx.ErrNoRows) { + return models.User{}, pgx.ErrNoRows + } + if err != nil { + return models.User{}, err + } + if bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) != nil { + return models.User{}, pgx.ErrNoRows + } + return u, nil +} + +func (r *UserRepository) GetByID(ctx context.Context, id int) (models.User, error) { + var u models.User + err := r.pool.QueryRow(ctx, ` + SELECT id, email, name, phone, address, created_at FROM users WHERE id = $1 + `, id).Scan(&u.ID, &u.Email, &u.Name, &u.Phone, &u.Address, &u.CreatedAt) + return u, err +} + +func (r *UserRepository) UpdateProfile(ctx context.Context, u *models.User) error { + _, err := r.pool.Exec(ctx, ` + UPDATE users SET name = $1, phone = $2, address = $3 WHERE id = $4 + `, u.Name, u.Phone, u.Address, u.ID) + return err +} + +func (r *UserRepository) EmailExists(ctx context.Context, email string) (bool, error) { + var exists bool + err := r.pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM users WHERE email = $1)`, email).Scan(&exists) + return exists, err +} diff --git a/migrations/004_users_cart_orders.sql b/migrations/004_users_cart_orders.sql new file mode 100644 index 0000000..dc02cdb --- /dev/null +++ b/migrations/004_users_cart_orders.sql @@ -0,0 +1,52 @@ +-- Пользователи, корзина, заказы + +CREATE TABLE IF NOT EXISTS users ( + id SERIAL PRIMARY KEY, + email VARCHAR(255) UNIQUE NOT NULL, + password_hash VARCHAR(255) NOT NULL, + name VARCHAR(255) NOT NULL DEFAULT '', + phone VARCHAR(50) NOT NULL DEFAULT '', + address TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS carts ( + id SERIAL PRIMARY KEY, + user_id INT REFERENCES users(id) ON DELETE CASCADE, + session_key VARCHAR(64) NOT NULL DEFAULT '', + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_carts_user ON carts (user_id) WHERE user_id IS NOT NULL; +CREATE UNIQUE INDEX IF NOT EXISTS idx_carts_session ON carts (session_key) WHERE user_id IS NULL; + +CREATE TABLE IF NOT EXISTS cart_items ( + id SERIAL PRIMARY KEY, + cart_id INT NOT NULL REFERENCES carts(id) ON DELETE CASCADE, + product_id INT NOT NULL REFERENCES products(id) ON DELETE CASCADE, + quantity INT NOT NULL DEFAULT 1 CHECK (quantity > 0), + UNIQUE (cart_id, product_id) +); + +CREATE TABLE IF NOT EXISTS orders ( + id SERIAL PRIMARY KEY, + user_id INT REFERENCES users(id) ON DELETE SET NULL, + guest_name VARCHAR(255) NOT NULL DEFAULT '', + guest_email VARCHAR(255) NOT NULL DEFAULT '', + guest_phone VARCHAR(50) NOT NULL DEFAULT '', + address TEXT NOT NULL DEFAULT '', + total NUMERIC(10, 2) NOT NULL DEFAULT 0, + status VARCHAR(50) NOT NULL DEFAULT 'new', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS order_items ( + id SERIAL PRIMARY KEY, + order_id INT NOT NULL REFERENCES orders(id) ON DELETE CASCADE, + product_id INT NOT NULL, + product_name VARCHAR(255) NOT NULL, + price NUMERIC(10, 2) NOT NULL, + quantity INT NOT NULL CHECK (quantity > 0) +); + +CREATE INDEX IF NOT EXISTS idx_orders_user ON orders (user_id);