Merge pull request #15 from anxi0uz/feature/back-15
feat: dashboard aggregates, notifications, websocket route tracking
This commit is contained in:
8
go.mod
8
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
|
||||
|
||||
16
go.sum
16
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=
|
||||
|
||||
68
internal/handler/hub.go
Normal file
68
internal/handler/hub.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
30
internal/models/dashboard.go
Normal file
30
internal/models/dashboard.go
Normal file
@@ -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"`
|
||||
}
|
||||
@@ -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()))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user