diff --git a/go.mod b/go.mod index a085957..a8129e5 100644 --- a/go.mod +++ b/go.mod @@ -36,6 +36,7 @@ require ( github.com/go-openapi/jsonpointer v0.21.0 // indirect github.com/go-openapi/swag v0.23.0 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/gorilla/websocket v1.5.3 // indirect github.com/gosimple/unidecode v1.0.1 // indirect github.com/huandu/go-clone v1.7.3 // indirect github.com/huandu/xstrings v1.4.0 // indirect @@ -59,17 +60,24 @@ require ( github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.66.1 // indirect github.com/prometheus/procfs v0.19.2 // indirect + github.com/richardlehane/mscfb v1.0.6 // indirect + github.com/richardlehane/msoleps v1.0.6 // indirect github.com/sethvargo/go-retry v0.3.0 // indirect github.com/speakeasy-api/jsonpath v0.6.0 // indirect github.com/speakeasy-api/openapi-overlay v0.10.2 // indirect + github.com/tiendc/go-deepcopy v1.7.2 // indirect github.com/vmware-labs/yaml-jsonpath v0.3.2 // indirect github.com/woodsbury/decimal128 v1.3.0 // indirect + github.com/xuri/efp v0.0.1 // indirect + github.com/xuri/excelize/v2 v2.10.1 // indirect + github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect go.opentelemetry.io/otel v1.40.0 // indirect go.opentelemetry.io/otel/trace v1.40.0 // indirect go.uber.org/atomic v1.11.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.yaml.in/yaml/v2 v2.4.2 // indirect golang.org/x/mod v0.32.0 // indirect + golang.org/x/net v0.50.0 // indirect golang.org/x/sys v0.41.0 // indirect golang.org/x/text v0.34.0 // indirect golang.org/x/tools v0.41.0 // indirect diff --git a/go.sum b/go.sum index 943a813..73cb62b 100644 --- a/go.sum +++ b/go.sum @@ -62,6 +62,8 @@ github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gosimple/slug v1.15.0 h1:wRZHsRrRcs6b0XnxMUBM6WK1U1Vg5B0R7VkIf1Xzobo= github.com/gosimple/slug v1.15.0/go.mod h1:UiRaFH+GEilHstLUmcBgWcI42viBN7mAb818JrYOeFQ= github.com/gosimple/unidecode v1.0.1 h1:hZzFTMMqSswvf0LBJZCZgThIZrpDHFXux9KeGmn6T/o= @@ -161,6 +163,10 @@ github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfS github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/richardlehane/mscfb v1.0.6 h1:eN3bvvZCp00bs7Zf52bxNwAx5lJDBK1tCuH19qq5aC8= +github.com/richardlehane/mscfb v1.0.6/go.mod h1:pe0+IUIc0AHh0+teNzBlJCtSyZdFOGgV4ZK9bsoV+Jo= +github.com/richardlehane/msoleps v1.0.6 h1:9BvkpjvD+iUBalUY4esMwv6uBkfOip/Lzvd93jvR9gg= +github.com/richardlehane/msoleps v1.0.6/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg= github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= github.com/samber/slog-chi v1.19.0 h1:fl4qH5Hhk7feHtyp4CxJUt7U1TqjPrZ1uueDW9D+Cps= @@ -180,10 +186,18 @@ github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5 github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tiendc/go-deepcopy v1.7.2 h1:Ut2yYR7W9tWjTQitganoIue4UGxZwCcJy3orjrrIj44= +github.com/tiendc/go-deepcopy v1.7.2/go.mod h1:4bKjNC2r7boYOkD2IOuZpYjmlDdzjbpTRyCx+goBCJQ= github.com/vmware-labs/yaml-jsonpath v0.3.2 h1:/5QKeCBGdsInyDCyVNLbXyilb61MXGi9NP674f9Hobk= github.com/vmware-labs/yaml-jsonpath v0.3.2/go.mod h1:U6whw1z03QyqgWdgXxvVnQ90zN1BWz5V+51Ewf8k+rQ= github.com/woodsbury/decimal128 v1.3.0 h1:8pffMNWIlC0O5vbyHWFZAt5yWvWcrHA+3ovIIjVWss0= github.com/woodsbury/decimal128 v1.3.0/go.mod h1:C5UTmyTjW3JftjUFzOVhC20BEQa2a4ZKOB5I6Zjb+ds= +github.com/xuri/efp v0.0.1 h1:fws5Rv3myXyYni8uwj2qKjVaRP30PdjeYe2Y6FDsCL8= +github.com/xuri/efp v0.0.1/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI= +github.com/xuri/excelize/v2 v2.10.1 h1:V62UlqopMqha3kOpnlHy2CcRVw1V8E63jFoWUmMzxN0= +github.com/xuri/excelize/v2 v2.10.1/go.mod h1:iG5tARpgaEeIhTqt3/fgXCGoBRt4hNXgCp3tfXKoOIc= +github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 h1:+C0TIdyyYmzadGaL/HBLbf3WdLgC29pgyhTjAT/0nuE= +github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= @@ -214,6 +228,8 @@ golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/ golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk= golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= +golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= +golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= diff --git a/internal/handler/hub.go b/internal/handler/hub.go new file mode 100644 index 0000000..c497946 --- /dev/null +++ b/internal/handler/hub.go @@ -0,0 +1,68 @@ +package handler + +import ( + "context" + "sync" + + "github.com/google/uuid" + "github.com/gorilla/websocket" +) + +type Hub struct { + mu sync.RWMutex + connections map[uuid.UUID][]*websocket.Conn + trackers map[uuid.UUID]context.CancelFunc +} + +func NewHub() *Hub { + return &Hub{ + connections: make(map[uuid.UUID][]*websocket.Conn), + trackers: make(map[uuid.UUID]context.CancelFunc), + } +} + +func (h *Hub) Register(orderID uuid.UUID, conn *websocket.Conn) { + h.mu.Lock() + defer h.mu.Unlock() + h.connections[orderID] = append(h.connections[orderID], conn) +} + +func (h *Hub) Unregister(orderID uuid.UUID, conn *websocket.Conn) { + h.mu.Lock() + defer h.mu.Unlock() + conns := h.connections[orderID] + for i, c := range conns { + if c == conn { + h.connections[orderID] = append(conns[:i], conns[i+1:]...) + break + } + } +} + +func (h *Hub) Broadcast(orderID uuid.UUID, msg any) { + h.mu.RLock() + defer h.mu.RUnlock() + for _, conn := range h.connections[orderID] { + conn.WriteJSON(msg) + } +} + +func (h *Hub) StartTracker(orderID uuid.UUID, fn func(ctx context.Context)) { + h.mu.Lock() + defer h.mu.Unlock() + if _, exists := h.trackers[orderID]; exists { + return + } + ctx, cancel := context.WithCancel(context.Background()) + h.trackers[orderID] = cancel + go fn(ctx) +} + +func (h *Hub) StopTracker(orderID uuid.UUID) { + h.mu.Lock() + defer h.mu.Unlock() + if cancel, exists := h.trackers[orderID]; exists { + cancel() + delete(h.trackers, orderID) + } +} diff --git a/internal/handler/metrics.go b/internal/handler/metrics.go index 375076d..8fff0ee 100644 --- a/internal/handler/metrics.go +++ b/internal/handler/metrics.go @@ -1,6 +1,9 @@ package handler import ( + "bufio" + "fmt" + "net" "net/http" "strconv" "time" @@ -45,6 +48,18 @@ type statusRecorder struct { status int } +func (r *statusRecorder) Hijack() (net.Conn, *bufio.ReadWriter, error) { + h, ok := r.ResponseWriter.(http.Hijacker) + if !ok { + return nil, nil, fmt.Errorf("hijack not supportd") + } + return h.Hijack() +} + +func (r *statusRecorder) Unwrap() http.ResponseWriter { + return r.ResponseWriter +} + func (r *statusRecorder) WriteHeader(status int) { r.status = status r.ResponseWriter.WriteHeader(status) diff --git a/internal/handler/notification.go b/internal/handler/notification.go index c2b1fd1..48a9592 100644 --- a/internal/handler/notification.go +++ b/internal/handler/notification.go @@ -1,14 +1,68 @@ package handler import ( + "log/slog" "net/http" "github.com/anxi0uz/logiflow/internal/api" + "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) ListNotifications(w http.ResponseWriter, r *http.Request, params api.ListNotificationsParams) { + 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 + } + + notifs, err := storage.GetAll[models.Notification](ctx, "notifications", s.DB, func(sb *sqlbuilder.SelectBuilder) { + sb.Where(sb.EQ("user_id", claims.ID)) + if params.UnreadOnly != nil && *params.UnreadOnly { + sb.Where(sb.EQ("is_read", false)) + } + }) + if err != nil { + slog.ErrorContext(ctx, "Error while getting notifications", slog.String("error", err.Error())) + s.JSON(w, r, http.StatusInternalServerError, MsgInternalError, RespError) + return + } + s.JSON(w, r, http.StatusOK, notifs, RespSuccess) } func (s *Server) MarkNotificationRead(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 + } + + notification, err := storage.GetOne[models.Notification](ctx, s.DB, "notifications", func(sb *sqlbuilder.SelectBuilder) { + sb.Where(sb.EQ("id", id)) + }) + if err != nil { + slog.ErrorContext(ctx, "Error while getting notification", slog.String("error", err.Error())) + s.JSON(w, r, http.StatusInternalServerError, MsgInternalError, RespError) + return + } + if notification.UserID != claims.ID { + s.JSON(w, r, http.StatusForbidden, MsgForbidden, RespError) + return + } + notification.IsRead = true + + if err := storage.Update(ctx, "notifications", *notification, s.DB, func(sb *sqlbuilder.UpdateBuilder) { + sb.Where(sb.EQ("id", id)) + }); err != nil { + slog.ErrorContext(ctx, "Error while updating notification", slog.String("error", err.Error())) + s.JSON(w, r, http.StatusInternalServerError, MsgInternalError, RespError) + return + } + s.JSON(w, r, http.StatusOK, "Updated", RespSuccess) } diff --git a/internal/handler/order.go b/internal/handler/order.go index 4e2e701..cb4e71c 100644 --- a/internal/handler/order.go +++ b/internal/handler/order.go @@ -10,6 +10,7 @@ import ( "github.com/anxi0uz/logiflow/internal/services" storage "github.com/anxi0uz/logiflow/pkg" openapi_types "github.com/oapi-codegen/runtime/types" + "github.com/xuri/excelize/v2" ) func (s *Server) ListOrders(w http.ResponseWriter, r *http.Request, params api.ListOrdersParams) { @@ -135,6 +136,9 @@ func (s *Server) UpdateOrderStatus(w http.ResponseWriter, r *http.Request, id op } return } + if req.Status == api.OrderStatusUpdateStatusInTransit { + go s.startRouteTracker(id) + } s.JSON(w, r, http.StatusOK, order, RespSuccess) } @@ -157,7 +161,64 @@ func (s *Server) GetOrdersReport(w http.ResponseWriter, r *http.Request, params s.JSON(w, r, http.StatusInternalServerError, MsgInternalError, RespError) return } - s.JSON(w, r, http.StatusOK, orders, RespSuccess) + + f := excelize.NewFile() + sheet := "Orders" + f.SetSheetName("Sheet1", sheet) + + headers := []string{"ID", "Status", "Origin", "Destination", "Weight", "Volume", "Price", "Created At"} + for i, h := range headers { + cell, _ := excelize.CoordinatesToCellName(i+1, 1) + f.SetCellValue(sheet, cell, h) + } + + for row, o := range orders { + values := []any{ + o.ID.String(), + o.Status, + o.OriginAddress, + o.DestinationAddress, + o.WeightKg, + o.VolumeM3, + o.TotalPrice, + o.CreatedAt.Format("2006-01-02 15:04:05"), + } + for col, v := range values { + cell, _ := excelize.CoordinatesToCellName(col+1, row+2) + f.SetCellValue(sheet, cell, v) + } + } + buf, err := f.WriteToBuffer() + if err != nil { + slog.ErrorContext(ctx, "excel write failed", slog.String("error", err.Error())) + s.JSON(w, r, http.StatusInternalServerError, MsgInternalError, RespError) + return + } + + w.Header().Set("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") + w.Header().Set("Content-Disposition", "attachment; filename=orders_report.xlsx") + w.WriteHeader(http.StatusOK) + w.Write(buf.Bytes()) } -func (s *Server) GetDashboard(w http.ResponseWriter, r *http.Request) {} +func (s *Server) GetDashboard(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 + } + + report, err := s.OrderSerice.GetDashboard(ctx, claims.Role) + if err != nil { + if errors.Is(err, services.ErrForbidden) { + s.JSON(w, r, http.StatusForbidden, MsgForbidden, RespError) + return + } + slog.ErrorContext(ctx, "dashboard failed", slog.String("error", err.Error())) + s.JSON(w, r, http.StatusInternalServerError, MsgInternalError, RespError) + return + } + s.JSON(w, r, http.StatusOK, report, RespSuccess) +} diff --git a/internal/handler/route.go b/internal/handler/route.go index de260b3..bb3137a 100644 --- a/internal/handler/route.go +++ b/internal/handler/route.go @@ -1,15 +1,23 @@ package handler import ( + "context" "log/slog" "net/http" + "time" "github.com/anxi0uz/logiflow/internal/models" storage "github.com/anxi0uz/logiflow/pkg" + "github.com/google/uuid" + "github.com/gorilla/websocket" "github.com/huandu/go-sqlbuilder" openapi_types "github.com/oapi-codegen/runtime/types" ) +var upgrader = websocket.Upgrader{ + CheckOrigin: func(r *http.Request) bool { return true }, +} + 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) { @@ -23,4 +31,81 @@ func (s *Server) GetRoute(w http.ResponseWriter, r *http.Request, id openapi_typ s.JSON(w, r, http.StatusOK, route, RespSuccess) } -func (s *Server) RouteWebSocket(w http.ResponseWriter, r *http.Request, id openapi_types.UUID) {} +func (s *Server) RouteWebSocket(w http.ResponseWriter, r *http.Request, id openapi_types.UUID) { + ctx := r.Context() + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + slog.ErrorContext(ctx, "ws upgrade failed", slog.String("error", err.Error())) + s.JSON(w, r, http.StatusInternalServerError, MsgInternalError, RespError) + return + } + defer conn.Close() + + orderID := id + s.Hub.Register(id, conn) + defer s.Hub.Unregister(orderID, conn) + + s.Hub.mu.RLock() + _, trackerRunning := s.Hub.trackers[orderID] + s.Hub.mu.RUnlock() + if !trackerRunning { + go s.startRouteTracker(orderID) + } + + route, err := storage.GetOne[models.Route](r.Context(), s.DB, "routes", func(sb *sqlbuilder.SelectBuilder) { + sb.Where(sb.EQ("order_id", orderID)) + }) + if err == nil { + coords, err := route.ParseCoordinates() + if err == nil && route.CurrentIndex < len(coords) { + conn.WriteJSON(map[string]any{ + "current_index": route.CurrentIndex, + "coordinate": coords[route.CurrentIndex], + }) + } + } + + for { + _, _, err := conn.ReadMessage() + if err != nil { + break + } + } +} + +func (s *Server) startRouteTracker(orderID uuid.UUID) { + s.Hub.StartTracker(orderID, func(ctx context.Context) { + for { + select { + case <-ctx.Done(): + return + case <-time.After(10 * time.Second): + route, err := storage.GetOne[models.Route](ctx, s.DB, "routes", func(sb *sqlbuilder.SelectBuilder) { + sb.Where(sb.EQ("order_id", orderID)) + }) + if err != nil { + return + } + + coords, err := route.ParseCoordinates() + if err != nil || len(coords) == 0 { + return + } + + if route.CurrentIndex >= len(coords) { + s.Hub.StopTracker(orderID) + return + } + + s.Hub.Broadcast(orderID, map[string]any{ + "current_index": route.CurrentIndex, + "coordinate": coords[route.CurrentIndex], + }) + route.CurrentIndex++ + storage.Update(ctx, "routes", *route, s.DB, func(sb *sqlbuilder.UpdateBuilder) { + sb.Where(sb.EQ("order_id", orderID)) + }) + } + } + }) +} diff --git a/internal/handler/server_impl.go b/internal/handler/server_impl.go index 0c6f066..304bc3c 100644 --- a/internal/handler/server_impl.go +++ b/internal/handler/server_impl.go @@ -55,6 +55,7 @@ type Server struct { Redis *redis.Client JwtKey []byte OrderSerice services.OrderServicer + Hub *Hub } func NewServer(db *pgxpool.Pool, redis *redis.Client, cfg *config.Config) *Server { @@ -65,6 +66,7 @@ func NewServer(db *pgxpool.Pool, redis *redis.Client, cfg *config.Config) *Serve Config: cfg, JwtKey: []byte(cfg.JwtOpt.Key), OrderSerice: services.NewOrderService(db, *cfg), + Hub: NewHub(), } } diff --git a/internal/models/dashboard.go b/internal/models/dashboard.go new file mode 100644 index 0000000..08daa6e --- /dev/null +++ b/internal/models/dashboard.go @@ -0,0 +1,30 @@ +package models + +import "github.com/google/uuid" + +type DashboardRevenue struct { + Total float64 `json:"total"` + ThisMonth float64 `json:"thisMonth"` +} + +type DashboardOrderStatus struct { + Total int `json:"total` + Delivered int `json:"delivered"` + InTransit int `json:"inTransit` + Pending int `json:"pending"` + Cancelled int `json:"cancelled"` +} + +type DashboardDriverStat struct { + ID uuid.UUID `json:"id"` + FullName string `json:"fullName"` + Status string `json:"status"` + Rating float64 `json:"rating"` + CompletedOrders int `json:"completedOrders"` +} + +type DashboardReport struct { + Revenue DashboardRevenue `json:"revenue"` + Orders DashboardOrderStatus `json:"orders"` + Drivers []DashboardDriverStat `json:"drivers"` +} diff --git a/internal/services/order.go b/internal/services/order.go index 3be5053..d917eb3 100644 --- a/internal/services/order.go +++ b/internal/services/order.go @@ -31,6 +31,7 @@ type OrderServicer interface { 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) + GetDashboard(ctx context.Context, role string) (*models.DashboardReport, error) } var ErrForbidden = errors.New("forbidden") @@ -283,9 +284,20 @@ func (s *OrderService) UpdateOrderStatus(ctx context.Context, id uuid.UUID, user } order.DriverID = req.DriverId order.AssignedAt = &now + driver, _ := storage.GetOne[models.Driver](ctx, s.db, "drivers", func(sb *sqlbuilder.SelectBuilder) { + sb.Where(sb.EQ("id", order.DriverID)) + }) + s.createNotification(ctx, driver.UserID, "Новый заказ", "Вам назначен новый заказ") + } + if req.Status == api.OrderStatusUpdateStatusInTransit { + if order.CreatedByID == nil { + return nil, fmt.Errorf("created by id needed") + } + s.createNotification(ctx, *order.CreatedByID, "Заказ в пути", "Ваш заказ передан водителю") } if req.Status == api.OrderStatusUpdateStatusDelivered { order.DeliveredAt = &now + s.createNotification(ctx, *order.CreatedByID, "Заказ доставлен", "Ваш заказ успешно доставлен") } if err := storage.Update(ctx, "orders", *order, s.db, func(sb *sqlbuilder.UpdateBuilder) { sb.Where(sb.EQ("id", id)) @@ -320,3 +332,89 @@ func (s *OrderService) GetOrdersReport(ctx context.Context, role string, params } return orders, nil } +func (s *OrderService) GetDashboard(ctx context.Context, role string) (*models.DashboardReport, error) { + if role != "manager" && role != "admin" { + return nil, ErrForbidden + } + + var report models.DashboardReport + g, gctx := errgroup.WithContext(ctx) + + // query 1: order counts + revenue + g.Go(func() error { + row := s.db.QueryRow(gctx, ` + SELECT + COUNT(*) AS total, + COUNT(*) FILTER (WHERE status = 'delivered') AS delivered, + COUNT(*) FILTER (WHERE status = 'in_transit') AS in_transit, + COUNT(*) FILTER (WHERE status = 'pending') AS pending, + COUNT(*) FILTER (WHERE status = 'cancelled') AS cancelled, + COALESCE(SUM(total_price) FILTER (WHERE status = 'delivered'), 0) AS revenue_total, + COALESCE(SUM(total_price) FILTER (WHERE status = 'delivered' + AND created_at >= date_trunc('month', NOW())), 0) AS revenue_this_month + FROM orders + `) + return row.Scan( + &report.Orders.Total, + &report.Orders.Delivered, + &report.Orders.InTransit, + &report.Orders.Pending, + &report.Orders.Cancelled, + &report.Revenue.Total, + &report.Revenue.ThisMonth, + ) + }) + + // query 2: top drivers by completed orders + g.Go(func() error { + rows, err := s.db.Query(gctx, ` + SELECT + d.id, + u.full_name, + d.status, + d.rating, + COUNT(o.id) FILTER (WHERE o.status = 'delivered') AS completed_orders + FROM drivers d + JOIN users u ON u.id = d.user_id + LEFT JOIN orders o ON o.driver_id = d.id + GROUP BY d.id, u.full_name, d.status, d.rating + ORDER BY completed_orders DESC + LIMIT 10 + `) + if err != nil { + return err + } + defer rows.Close() + + for rows.Next() { + var ds models.DashboardDriverStat + if err := rows.Scan(&ds.ID, &ds.FullName, &ds.Status, &ds.Rating, &ds.CompletedOrders); err != nil { + return err + } + report.Drivers = append(report.Drivers, ds) + } + return rows.Err() + }) + + if err := g.Wait(); err != nil { + return nil, err + } + if report.Drivers == nil { + report.Drivers = []models.DashboardDriverStat{} + } + return &report, nil +} + +func (s *OrderService) createNotification(ctx context.Context, userID uuid.UUID, title, body string) { + n := models.Notification{ + ID: uuid.New(), + UserID: userID, + Title: title, + Body: &body, + IsRead: false, + CreatedAt: time.Now(), + } + if err := storage.Create(ctx, "notifications", n, s.db); err != nil { + slog.ErrorContext(ctx, "failed to create notification", slog.String("error", err.Error())) + } +} diff --git a/tests/order_handler_test.go b/tests/order_handler_test.go index 95f350b..d74d90d 100644 --- a/tests/order_handler_test.go +++ b/tests/order_handler_test.go @@ -25,6 +25,7 @@ type mockOrderService struct { cancelOrder func(ctx context.Context, id uuid.UUID, userID uuid.UUID, role string) error updateOrderStatus func(ctx context.Context, id uuid.UUID, userID uuid.UUID, role string, req api.OrderStatusUpdate) (*models.Order, error) getOrdersReport func(ctx context.Context, role string, params api.GetOrdersReportParams) ([]models.Order, error) + getDashboard func(ctx context.Context, role string) (*models.DashboardReport, error) } func (m *mockOrderService) CreateOrder(ctx context.Context, req api.OrderCreate, userID uuid.UUID) (*services.CreateOrderResult, error) { @@ -45,6 +46,9 @@ func (m *mockOrderService) UpdateOrderStatus(ctx context.Context, id uuid.UUID, func (m *mockOrderService) GetOrdersReport(ctx context.Context, role string, params api.GetOrdersReportParams) ([]models.Order, error) { return m.getOrdersReport(ctx, role, params) } +func (m *mockOrderService) GetDashboard(ctx context.Context, role string) (*models.DashboardReport, error) { + return m.getDashboard(ctx, role) +} // --- Helpers --- @@ -429,3 +433,63 @@ func TestGetOrdersReport_ServiceError(t *testing.T) { t.Errorf("expected 500, got %d", w.Code) } } + +// --- GetDashboard --- + +func TestGetDashboard_Success(t *testing.T) { + svc := &mockOrderService{ + getDashboard: func(_ context.Context, _ string) (*models.DashboardReport, error) { + return &models.DashboardReport{ + Revenue: models.DashboardRevenue{Total: 10000, ThisMonth: 3000}, + Orders: models.DashboardOrderStatus{Total: 5, Delivered: 3, Pending: 2}, + Drivers: []models.DashboardDriverStat{}, + }, nil + }, + } + s := newTestServer(svc) + r := httptest.NewRequest(http.MethodGet, "/reports/dashboard", nil) + r = withClaims(r, uuid.New(), "manager") + w := httptest.NewRecorder() + + s.GetDashboard(w, r) + + if w.Code != http.StatusOK { + t.Errorf("expected 200, got %d", w.Code) + } +} + +func TestGetDashboard_Forbidden(t *testing.T) { + svc := &mockOrderService{ + getDashboard: func(_ context.Context, _ string) (*models.DashboardReport, error) { + return nil, services.ErrForbidden + }, + } + s := newTestServer(svc) + r := httptest.NewRequest(http.MethodGet, "/reports/dashboard", nil) + r = withClaims(r, uuid.New(), "client") + w := httptest.NewRecorder() + + s.GetDashboard(w, r) + + if w.Code != http.StatusForbidden { + t.Errorf("expected 403, got %d", w.Code) + } +} + +func TestGetDashboard_ServiceError(t *testing.T) { + svc := &mockOrderService{ + getDashboard: func(_ context.Context, _ string) (*models.DashboardReport, error) { + return nil, errors.New("db error") + }, + } + s := newTestServer(svc) + r := httptest.NewRequest(http.MethodGet, "/reports/dashboard", nil) + r = withClaims(r, uuid.New(), "manager") + w := httptest.NewRecorder() + + s.GetDashboard(w, r) + + if w.Code != http.StatusInternalServerError { + t.Errorf("expected 500, got %d", w.Code) + } +}