feat: implement order workflow, metrics, and handler unit tests

- implement UpdateOrderStatus and GetOrdersReport service methods
- implement all order, user handlers (UpdateMe, GetMyTrips, CancelOrder, GetOrder, etc.)
- extract OrderServicer interface for testability
- add Prometheus metrics middleware (requests total, duration)
- fix GetAll storage flavor for PostgreSQL ($1 placeholders)
- add 17 unit tests for order handlers via httptest
- wire Grafana datasource and update Prometheus scrape config
- update README with full API reference, pricing, roles, monitoring
This commit is contained in:
2026-04-16 17:59:06 +03:00
parent 2d96a1a135
commit 5a45c8b52e
14 changed files with 1142 additions and 53 deletions

View File

@@ -70,6 +70,7 @@ func (s *Server) CreateManager(w http.ResponseWriter, r *http.Request) {
if err := storage.Create(ctx, "users", userModel, tx); err != nil {
slog.ErrorContext(ctx, "Error while creating user", slog.String("error", err.Error()))
s.JSON(w, r, http.StatusInternalServerError, MsgInternalError, RespError)
tx.Rollback(ctx)
return
}
@@ -83,6 +84,7 @@ func (s *Server) CreateManager(w http.ResponseWriter, r *http.Request) {
if err := storage.Create(ctx, "managers", managerModel, tx); err != nil {
slog.ErrorContext(ctx, "Error while creating manager", slog.String("error", err.Error()))
s.JSON(w, r, http.StatusInternalServerError, MsgInternalError, RespError)
tx.Rollback(ctx)
return
}

View File

@@ -0,0 +1,51 @@
package handler
import (
"net/http"
"strconv"
"time"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
)
var (
httpRequests = promauto.NewCounterVec(prometheus.CounterOpts{
Name: "logiflow_http_requests_total",
Help: "Количество HTTP запросов",
}, []string{"method", "path", "status"})
httpDuration = promauto.NewHistogramVec(prometheus.HistogramOpts{
Name: "logiflow_http_duration_seconds",
Help: "Время обработки HTTP запросов",
Buckets: prometheus.DefBuckets,
}, []string{"method", "path"})
)
func (s *Server) MiddlewareMetrics(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
wrapped := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(wrapped, r)
httpRequests.WithLabelValues(
r.Method,
r.URL.Path,
strconv.Itoa(wrapped.status),
).Inc()
httpDuration.WithLabelValues(r.Method, r.URL.Path).
Observe(time.Since(start).Seconds())
})
}
type statusRecorder struct {
http.ResponseWriter
status int
}
func (r *statusRecorder) WriteHeader(status int) {
r.status = status
r.ResponseWriter.WriteHeader(status)
}

View File

@@ -2,10 +2,13 @@ package handler
import (
"encoding/json"
"errors"
"log/slog"
"net/http"
"github.com/anxi0uz/logiflow/internal/api"
"github.com/anxi0uz/logiflow/internal/services"
storage "github.com/anxi0uz/logiflow/pkg"
openapi_types "github.com/oapi-codegen/runtime/types"
)
@@ -17,10 +20,13 @@ func (s *Server) ListOrders(w http.ResponseWriter, r *http.Request, params api.L
s.JSON(w, r, http.StatusInternalServerError, MsgInternalError, RespError)
return
}
switch claims.Role {
case "client":
break
orders, err := s.OrderSerice.ListOrders(ctx, claims.ID, claims.Role, params)
if err != nil {
slog.ErrorContext(ctx, "Error while getting list of orders", slog.String("error", err.Error()))
s.JSON(w, r, http.StatusInternalServerError, MsgInternalError, RespError)
return
}
s.JSON(w, r, http.StatusOK, orders, RespSuccess)
}
func (s *Server) CreateOrder(w http.ResponseWriter, r *http.Request) {
@@ -51,13 +57,107 @@ func (s *Server) CreateOrder(w http.ResponseWriter, r *http.Request) {
}, "order")
}
func (s *Server) GetOrder(w http.ResponseWriter, r *http.Request, id openapi_types.UUID) {}
func (s *Server) GetOrder(w http.ResponseWriter, r *http.Request, id openapi_types.UUID) {
ctx := r.Context()
claims, ok := ctx.Value("user").(*Claims)
if !ok {
slog.ErrorContext(ctx, "Error while casting claims")
s.JSON(w, r, http.StatusInternalServerError, MsgInternalError, RespError)
return
}
func (s *Server) CancelOrder(w http.ResponseWriter, r *http.Request, id openapi_types.UUID) {}
order, err := s.OrderSerice.GetOrder(ctx, id, claims.ID, claims.Role)
if err != nil {
if errors.Is(err, services.ErrForbidden) {
s.JSON(w, r, http.StatusForbidden, MsgForbidden, RespError)
return
}
if errors.Is(err, storage.ErrNotFound) {
s.JSON(w, r, http.StatusNotFound, MsgNotFound, RespNotFound)
return
}
slog.ErrorContext(ctx, "Error while getting order", slog.String("error", err.Error()))
s.JSON(w, r, http.StatusInternalServerError, MsgInternalError, RespError)
return
}
s.JSON(w, r, http.StatusOK, order, RespSuccess)
}
func (s *Server) UpdateOrderStatus(w http.ResponseWriter, r *http.Request, id openapi_types.UUID) {}
func (s *Server) CancelOrder(w http.ResponseWriter, r *http.Request, id openapi_types.UUID) {
ctx := r.Context()
claims, ok := ctx.Value("user").(*Claims)
if !ok {
slog.ErrorContext(ctx, "Error while casting claims")
s.JSON(w, r, http.StatusInternalServerError, MsgInternalError, RespError)
return
}
if err := s.OrderSerice.CancelOrder(ctx, id, claims.ID, claims.Role); err != nil {
if errors.Is(err, services.ErrCannotCancel) {
slog.ErrorContext(ctx, "Cant cancel order with that id", slog.String("id", id.String()))
s.JSON(w, r, http.StatusConflict, "order cant be cancelled in current status", RespError)
return
}
if errors.Is(err, services.ErrForbidden) {
s.JSON(w, r, http.StatusForbidden, MsgForbidden, RespError)
return
}
slog.ErrorContext(ctx, "error while cancelling order with that id", slog.Any("id", id), slog.String("error", err.Error()))
s.JSON(w, r, http.StatusInternalServerError, MsgInternalError, RespError)
return
}
s.JSON(w, r, http.StatusOK, "Cancelled", RespSuccess)
}
func (s *Server) UpdateOrderStatus(w http.ResponseWriter, r *http.Request, id openapi_types.UUID) {
ctx := r.Context()
claims, ok := ctx.Value("user").(*Claims)
if !ok {
slog.ErrorContext(ctx, "Error while casting claims")
s.JSON(w, r, http.StatusInternalServerError, MsgInternalError, RespError)
return
}
var req api.OrderStatusUpdate
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
s.JSON(w, r, http.StatusBadRequest, MsgInvalidBody, RespError)
return
}
order, err := s.OrderSerice.UpdateOrderStatus(ctx, id, claims.ID, claims.Role, req)
if err != nil {
switch {
case errors.Is(err, services.ErrForbidden):
s.JSON(w, r, http.StatusForbidden, MsgForbidden, RespError)
case errors.Is(err, services.ErrCannotCancel):
s.JSON(w, r, http.StatusConflict, "cannot cancel order in current status", RespError)
default:
slog.ErrorContext(ctx, "...", slog.String("error", err.Error()))
s.JSON(w, r, http.StatusInternalServerError, MsgInternalError, RespError)
}
return
}
s.JSON(w, r, http.StatusOK, order, RespSuccess)
}
func (s *Server) GetOrdersReport(w http.ResponseWriter, r *http.Request, params api.GetOrdersReportParams) {
ctx := r.Context()
claims, ok := ctx.Value("user").(*Claims)
if !ok {
slog.ErrorContext(ctx, "Error while casting claims")
s.JSON(w, r, http.StatusInternalServerError, MsgInternalError, RespError)
return
}
orders, err := s.OrderSerice.GetOrdersReport(ctx, claims.Role, params)
if err != nil {
if errors.Is(err, services.ErrForbidden) {
s.JSON(w, r, http.StatusForbidden, MsgForbidden, RespError)
return
}
slog.ErrorContext(ctx, "Error while getting orders report", slog.String("error", err.Error()))
s.JSON(w, r, http.StatusInternalServerError, MsgInternalError, RespError)
return
}
s.JSON(w, r, http.StatusOK, orders, RespSuccess)
}
func (s *Server) GetDashboard(w http.ResponseWriter, r *http.Request) {}

View File

@@ -1,11 +1,26 @@
package handler
import (
"log/slog"
"net/http"
"github.com/anxi0uz/logiflow/internal/models"
storage "github.com/anxi0uz/logiflow/pkg"
"github.com/huandu/go-sqlbuilder"
openapi_types "github.com/oapi-codegen/runtime/types"
)
func (s *Server) GetRoute(w http.ResponseWriter, r *http.Request, id openapi_types.UUID) {}
func (s *Server) GetRoute(w http.ResponseWriter, r *http.Request, id openapi_types.UUID) {
ctx := r.Context()
route, err := storage.GetOne[models.Route](ctx, s.DB, "routes", func(sb *sqlbuilder.SelectBuilder) {
sb.Where(sb.EQ("order_id", id))
})
if err != nil {
slog.ErrorContext(ctx, "Error while getting route", slog.String("error", err.Error()))
s.JSON(w, r, http.StatusInternalServerError, MsgInternalError, RespError)
return
}
s.JSON(w, r, http.StatusOK, route, RespSuccess)
}
func (s *Server) RouteWebSocket(w http.ResponseWriter, r *http.Request, id openapi_types.UUID) {}

View File

@@ -17,6 +17,7 @@ import (
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/prometheus/client_golang/prometheus/promhttp"
"github.com/redis/go-redis/v9"
"github.com/rs/xid"
slogchi "github.com/samber/slog-chi"
@@ -53,7 +54,7 @@ type Server struct {
ctx context.Context
Redis *redis.Client
JwtKey []byte
OrderSerice services.OrderService
OrderSerice services.OrderServicer
}
func NewServer(db *pgxpool.Pool, redis *redis.Client, cfg *config.Config) *Server {
@@ -63,7 +64,7 @@ func NewServer(db *pgxpool.Pool, redis *redis.Client, cfg *config.Config) *Serve
ctx: context.Background(),
Config: cfg,
JwtKey: []byte(cfg.JwtOpt.Key),
OrderSerice: *services.NewOrderService(db, *cfg),
OrderSerice: services.NewOrderService(db, *cfg),
}
}
@@ -79,7 +80,7 @@ func (s *Server) Run() error {
}))
r.Use(s.MiddlewareRequestID)
r.Use(s.MiddlewareMetrics)
r.Use(slogchi.NewWithConfig(slog.Default(), slogchi.Config{
DefaultLevel: slog.LevelInfo,
ClientErrorLevel: slog.LevelWarn, // 400499 → Warn
@@ -93,6 +94,7 @@ func (s *Server) Run() error {
r.Use(s.AuthMiddleware)
h := api.HandlerFromMux(s, r)
r.Handle("/metrics", promhttp.Handler())
srv := &http.Server{
Handler: h,
@@ -121,7 +123,7 @@ func (s *Server) Run() error {
func (s *Server) AuthMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/auth/register" || r.URL.Path == "/auth/login" {
if r.URL.Path == "/auth/register" || r.URL.Path == "/auth/login" || r.URL.Path == "/metrics" {
next.ServeHTTP(w, r)
return
}

View File

@@ -263,9 +263,99 @@ func (s *Server) GetMe(w http.ResponseWriter, r *http.Request) {
}
s.JSON(w, r, http.StatusOK, user, "ok")
}
func (s *Server) UpdateMe(w http.ResponseWriter, r *http.Request) {}
func (s *Server) UpdateMe(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
claims, ok := ctx.Value("user").(*Claims)
if !ok {
slog.ErrorContext(ctx, "Error while casting claims")
s.JSON(w, r, http.StatusInternalServerError, MsgInternalError, RespError)
return
}
func (s *Server) GetMyTrips(w http.ResponseWriter, r *http.Request) {}
var req api.UserUpdate
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
s.JSON(w, r, http.StatusBadRequest, MsgInvalidBody, RespError)
return
}
user, err := storage.GetOne[models.User](ctx, s.DB, "users", func(sb *sqlbuilder.SelectBuilder) {
sb.Where(sb.EQ("id", claims.ID))
})
if err != nil {
slog.ErrorContext(ctx, "user not found", slog.String("error", err.Error()))
s.JSON(w, r, http.StatusInternalServerError, MsgInternalError, RespError)
return
}
if req.FullName != nil {
user.FullName = *req.FullName
}
if req.AvatarUrl != nil {
user.AvatarURL = *req.AvatarUrl
}
if req.Password != nil {
if req.CurrentPassword == nil {
s.JSON(w, r, http.StatusBadRequest, "currentPassword is required", RespError)
return
}
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(*req.CurrentPassword)); err != nil {
s.JSON(w, r, http.StatusBadRequest, "wrong current password", RespError)
return
}
hash, err := bcrypt.GenerateFromPassword([]byte(*req.Password), bcrypt.DefaultCost)
if err != nil {
slog.ErrorContext(ctx, "bcrypt failed", slog.String("error", err.Error()))
s.JSON(w, r, http.StatusInternalServerError, MsgInternalError, RespError)
return
}
user.PasswordHash = string(hash)
}
user.UpdatedAt = time.Now()
if err := storage.Update(ctx, "users", *user, s.DB, func(sb *sqlbuilder.UpdateBuilder) {
sb.Where(sb.Equal("id", claims.ID))
}); err != nil {
slog.ErrorContext(ctx, "update user failed", slog.String("error", err.Error()))
s.JSON(w, r, http.StatusInternalServerError, MsgInternalError, RespError)
return
}
s.JSON(w, r, http.StatusOK, user, RespSuccess)
}
func (s *Server) GetMyTrips(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
claims, ok := ctx.Value("user").(*Claims)
if !ok {
slog.ErrorContext(ctx, "Error while casting claims")
s.JSON(w, r, http.StatusInternalServerError, MsgInternalError, RespError)
return
}
if claims.Role != "driver" {
s.JSON(w, r, http.StatusForbidden, MsgForbidden, RespError)
return
}
driver, err := storage.GetOne[models.Driver](ctx, s.DB, "drivers", func(sb *sqlbuilder.SelectBuilder) {
sb.Where(sb.EQ("user_id", claims.ID))
})
if err != nil {
slog.ErrorContext(ctx, "error while getting driver", slog.String("error", err.Error()))
s.JSON(w, r, http.StatusInternalServerError, MsgInternalError, RespError)
return
}
orders, err := storage.GetAll[models.Order](ctx, "orders", s.DB, func(sb *sqlbuilder.SelectBuilder) {
sb.Where(sb.EQ("driver_id", driver.ID))
})
if err != nil {
slog.ErrorContext(ctx, "Error while getting orders", slog.String("error", err.Error()))
s.JSON(w, r, http.StatusInternalServerError, MsgInternalError, RespError)
return
}
s.JSON(w, r, http.StatusOK, orders, RespSuccess)
}
func (s *Server) issueTokens(w http.ResponseWriter, r *http.Request, user *models.User) {
access, err := s.generateAccessToken(user, s.Config.RedisAccessTokenDur())

View File

@@ -24,8 +24,17 @@ type OrderService struct {
db *pgxpool.Pool
config config.Config
}
type OrderServicer interface {
CreateOrder(ctx context.Context, req api.OrderCreate, userID uuid.UUID) (*CreateOrderResult, error)
ListOrders(ctx context.Context, userID uuid.UUID, role string, params api.ListOrdersParams) ([]models.Order, error)
GetOrder(ctx context.Context, id uuid.UUID, userID uuid.UUID, role string) (*models.Order, error)
CancelOrder(ctx context.Context, id uuid.UUID, userID uuid.UUID, role string) error
UpdateOrderStatus(ctx context.Context, id uuid.UUID, userID uuid.UUID, role string, req api.OrderStatusUpdate) (*models.Order, error)
GetOrdersReport(ctx context.Context, role string, params api.GetOrdersReportParams) ([]models.Order, error)
}
var ErrForbidden = errors.New("forbidden")
var ErrCannotCancel = errors.New("cant cancel")
type CreateOrderResult struct {
Order models.Order
@@ -169,7 +178,7 @@ func (s *OrderService) CreateOrder(ctx context.Context, req api.OrderCreate, use
func (s *OrderService) ListOrders(ctx context.Context, userID uuid.UUID, role string, params api.ListOrdersParams) ([]models.Order, error) {
var driverID *uuid.UUID
if role == "driver" {
driver, err := storage.GetOne[models.Driver](ctx, s.db, "driver", func(sb *sqlbuilder.SelectBuilder) {
driver, err := storage.GetOne[models.Driver](ctx, s.db, "drivers", func(sb *sqlbuilder.SelectBuilder) {
sb.Where(sb.EQ("user_id", userID))
})
if err != nil {
@@ -177,6 +186,7 @@ func (s *OrderService) ListOrders(ctx context.Context, userID uuid.UUID, role st
}
driverID = &driver.ID
}
orders, err := storage.GetAll[models.Order](ctx, "orders", s.db, func(sb *sqlbuilder.SelectBuilder) {
switch role {
case "client":
@@ -192,6 +202,7 @@ func (s *OrderService) ListOrders(ctx context.Context, userID uuid.UUID, role st
}
}
})
if err != nil {
return nil, fmt.Errorf("list orders: %w", err)
}
@@ -201,14 +212,111 @@ func (s *OrderService) GetOrder(ctx context.Context, id uuid.UUID, userID uuid.U
order, err := storage.GetOne[models.Order](ctx, s.db, "orders", func(sb *sqlbuilder.SelectBuilder) {
sb.Where(sb.EQ("id", id))
})
if err != nil {
return nil, err
}
if role == "client" && (order.CreatedByID == nil || *order.CreatedByID != userID) {
return nil, ErrForbidden
}
return order, nil
}
func (s *OrderService) CancelOrder(ctx context.Context, id uuid.UUID, userID uuid.UUID, role string) error
func (s *OrderService) UpdateOrderStatus(ctx context.Context, id uuid.UUID, userID uuid.UUID, role string, req api.OrderStatusUpdate) (*models.Order, error)
func (s *OrderService) GetOrdersReport(ctx context.Context, role string, params api.GetOrdersReportParams) ([]models.Order, error)
func (s *OrderService) CancelOrder(ctx context.Context, id uuid.UUID, userID uuid.UUID, role string) error {
order, err := storage.GetOne[models.Order](ctx, s.db, "orders", func(sb *sqlbuilder.SelectBuilder) {
sb.Where(sb.EQ("id", id))
})
if err != nil {
return err
}
if role == "client" && (order.CreatedByID == nil || *order.CreatedByID != userID) {
return ErrForbidden
}
if order.Status != "pending" {
return ErrCannotCancel
}
order.Status = "cancelled"
if err := storage.Update(ctx, "orders", order, s.db, func(sb *sqlbuilder.UpdateBuilder) {
sb.Where(sb.EQ("id", id))
}); err != nil {
return err
}
return nil
}
func (s *OrderService) UpdateOrderStatus(ctx context.Context, id uuid.UUID, userID uuid.UUID, role string, req api.OrderStatusUpdate) (*models.Order, error) {
if role == "client" {
return nil, ErrForbidden
}
order, err := storage.GetOne[models.Order](ctx, s.db, "orders", func(sb *sqlbuilder.SelectBuilder) {
sb.Where(sb.EQ("id", id))
})
if err != nil {
return nil, err
}
if role == "driver" {
driver, err := storage.GetOne[models.Driver](ctx, s.db, "drivers", func(sb *sqlbuilder.SelectBuilder) {
sb.Where(sb.EQ("user_id", userID))
})
if err != nil {
return nil, fmt.Errorf("get driver: %w", err)
}
if order.DriverID == nil || *order.DriverID != driver.ID {
return nil, ErrForbidden
}
if req.Status != api.OrderStatusUpdateStatusInTransit && req.Status != api.OrderStatusUpdateStatusDelivered {
return nil, ErrForbidden
}
}
if !req.Status.Valid() {
return nil, fmt.Errorf("invalid status: %s", req.Status)
}
now := time.Now()
order.Status = string(req.Status)
if req.Status == api.OrderStatusUpdateStatusAssigned {
if req.DriverId == nil {
return nil, fmt.Errorf("driverId required when assigned")
}
order.DriverID = req.DriverId
order.AssignedAt = &now
}
if req.Status == api.OrderStatusUpdateStatusDelivered {
order.DeliveredAt = &now
}
if err := storage.Update(ctx, "orders", *order, s.db, func(sb *sqlbuilder.UpdateBuilder) {
sb.Where(sb.EQ("id", id))
}); err != nil {
return nil, fmt.Errorf("update order: %w", err)
}
return order, nil
}
func (s *OrderService) GetOrdersReport(ctx context.Context, role string, params api.GetOrdersReportParams) ([]models.Order, error) {
if role != "manager" {
return nil, ErrForbidden
}
orders, err := storage.GetAll[models.Order](ctx, "orders", s.db, func(sb *sqlbuilder.SelectBuilder) {
if params.Status != nil {
sb.Where(sb.EQ("status", string(*params.Status)))
}
if params.DriverId != nil {
sb.Where(sb.EQ("driver_id", *params.DriverId))
}
if params.WarehouseId != nil {
sb.Where(sb.EQ("origin_warehouse_id", *params.WarehouseId))
}
if params.From != nil {
sb.Where(sb.GE("created_at", params.From.Time))
}
if params.To != nil {
sb.Where(sb.LE("created_at", params.To.Time))
}
})
if err != nil {
return nil, fmt.Errorf("get orders report: %w", err)
}
return orders, nil
}