to much to describe, everything in PR

This commit is contained in:
2026-03-24 20:25:18 +05:00
parent 36f2551c60
commit 87df56f41e
13 changed files with 1138 additions and 290 deletions

View File

@@ -22,3 +22,9 @@ accessTokenTTL = "24h"
[jwt] [jwt]
issuer = "logiflow-server" issuer = "logiflow-server"
audience = "logiflow-client" audience = "logiflow-client"
[pricing]
baseFee = 500.0
perKm = 25.0
perKg = 3.0
perM3 = 150.0

View File

@@ -10,9 +10,6 @@ servers:
- url: http://localhost:3001 - url: http://localhost:3001
description: Local development description: Local development
security:
- BearerAuth: []
components: components:
securitySchemes: securitySchemes:
BearerAuth: BearerAuth:
@@ -53,7 +50,7 @@ components:
RegisterRequest: RegisterRequest:
type: object type: object
required: [email, password, fullName, role] required: [email, password, fullName]
properties: properties:
email: email:
type: string type: string
@@ -63,9 +60,6 @@ components:
minLength: 8 minLength: 8
fullName: fullName:
type: string type: string
role:
type: string
enum: [admin, manager, driver, client]
LoginRequest: LoginRequest:
type: object type: object
@@ -323,11 +317,16 @@ components:
ManagerCreate: ManagerCreate:
type: object type: object
required: [userId] required: [email, password, fullName]
properties: properties:
userId: email:
type: string
format: email
password:
type: string
minLength: 8
fullName:
type: string type: string
format: uuid
warehouseId: warehouseId:
type: string type: string
format: uuid format: uuid
@@ -363,11 +362,16 @@ components:
DriverCreate: DriverCreate:
type: object type: object
required: [userId, licenseNumber, licenseExpiry] required: [email, password, fullName, licenseNumber, licenseExpiry]
properties: properties:
userId: email:
type: string
format: email
password:
type: string
minLength: 8
fullName:
type: string type: string
format: uuid
vehicleId: vehicleId:
type: string type: string
format: uuid format: uuid
@@ -488,6 +492,84 @@ components:
format: uuid format: uuid
nullable: true nullable: true
# ─── Notification ─────────────────────────────────────────────────────────
Notification:
type: object
properties:
id:
type: string
format: uuid
userId:
type: string
format: uuid
title:
type: string
body:
type: string
nullable: true
isRead:
type: boolean
createdAt:
type: string
format: date-time
# ─── Report ───────────────────────────────────────────────────────────────
DriverStatusUpdate:
type: object
required: [status]
properties:
status:
type: string
enum: [available, on_route, off_duty]
# ─── Dashboard ────────────────────────────────────────────────────────────
DashboardDriverStat:
type: object
properties:
id:
type: string
format: uuid
fullName:
type: string
status:
type: string
enum: [available, on_route, off_duty]
rating:
type: number
completedOrders:
type: integer
DashboardReport:
type: object
properties:
revenue:
type: object
properties:
total:
type: number
thisMonth:
type: number
orders:
type: object
properties:
total:
type: integer
delivered:
type: integer
inTransit:
type: integer
pending:
type: integer
cancelled:
type: integer
drivers:
type: array
items:
$ref: "#/components/schemas/DashboardDriverStat"
# ─── Route ──────────────────────────────────────────────────────────────── # ─── Route ────────────────────────────────────────────────────────────────
Coordinate: Coordinate:
@@ -555,7 +637,8 @@ paths:
/auth/register: /auth/register:
post: post:
operationId: authRegister operationId: authRegister
summary: Регистрация нового пользователя summary: Регистрация клиента
description: Публичный эндпоинт — создаёт аккаунт с ролью client. Для создания менеджеров и водителей используйте POST /managers и POST /drivers (только admin).
tags: [Auth] tags: [Auth]
security: [] security: []
requestBody: requestBody:
@@ -706,6 +789,22 @@ paths:
"401": "401":
description: Не авторизован description: Не авторизован
/me/trips:
get:
operationId: getMyTrips
summary: Поездки текущего водителя
tags: [Me]
description: Доступно только для роли driver. Возвращает маршруты, назначенные на текущего водителя.
responses:
"200":
description: Список маршрутов
content:
application/json:
schema:
$ref: "#/components/schemas/ApiResponse"
"403":
description: Доступно только водителям
# ─── Vehicles ─────────────────────────────────────────────────────────────── # ─── Vehicles ───────────────────────────────────────────────────────────────
/vehicles: /vehicles:
@@ -899,7 +998,8 @@ paths:
post: post:
operationId: createManager operationId: createManager
summary: Назначить менеджера summary: Создать менеджера
description: Только для роли admin. Создаёт пользователя с ролью manager и профиль менеджера в одной транзакции.
tags: [Managers] tags: [Managers]
requestBody: requestBody:
required: true required: true
@@ -954,6 +1054,13 @@ paths:
operationId: listDrivers operationId: listDrivers
summary: Список водителей summary: Список водителей
tags: [Drivers] tags: [Drivers]
parameters:
- name: status
in: query
required: false
schema:
type: string
enum: [available, on_route, off_duty]
responses: responses:
"200": "200":
description: Список водителей description: Список водителей
@@ -964,7 +1071,8 @@ paths:
post: post:
operationId: createDriver operationId: createDriver
summary: Зарегистрировать водителя summary: Создать водителя
description: Только для роли admin. Создаёт пользователя с ролью driver и профиль водителя в одной транзакции.
tags: [Drivers] tags: [Drivers]
requestBody: requestBody:
required: true required: true
@@ -1030,6 +1138,27 @@ paths:
"200": "200":
description: Водитель удалён description: Водитель удалён
/drivers/me/status:
patch:
operationId: updateMyDriverStatus
summary: Водитель меняет свой статус
tags: [Drivers]
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/DriverStatusUpdate"
responses:
"200":
description: Статус обновлён
content:
application/json:
schema:
$ref: "#/components/schemas/ApiResponse"
"403":
description: Доступно только водителям
# ─── Orders ───────────────────────────────────────────────────────────────── # ─── Orders ─────────────────────────────────────────────────────────────────
/orders: /orders:
@@ -1040,6 +1169,19 @@ paths:
description: > description: >
Клиент видит только свои заявки, менеджер — заявки своего склада, Клиент видит только свои заявки, менеджер — заявки своего склада,
водитель — назначенные на него, администратор — все. водитель — назначенные на него, администратор — все.
parameters:
- name: status
in: query
required: false
schema:
type: string
enum: [pending, assigned, in_transit, delivered, cancelled]
- name: driverId
in: query
required: false
schema:
type: string
format: uuid
responses: responses:
"200": "200":
description: Список заявок description: Список заявок
@@ -1191,3 +1333,110 @@ paths:
description: Switching Protocols — WebSocket установлен description: Switching Protocols — WebSocket установлен
"404": "404":
description: Маршрут не найден description: Маршрут не найден
# ─── Notifications ───────────────────────────────────────────────────────────
/notifications:
get:
operationId: listNotifications
summary: Список уведомлений текущего пользователя
tags: [Notifications]
parameters:
- name: unreadOnly
in: query
required: false
schema:
type: boolean
responses:
"200":
description: Список уведомлений
content:
application/json:
schema:
$ref: "#/components/schemas/ApiResponse"
"401":
description: Не авторизован
/notifications/{id}/read:
parameters:
- name: id
in: path
required: true
schema:
type: string
format: uuid
patch:
operationId: markNotificationRead
summary: Отметить уведомление как прочитанное
tags: [Notifications]
responses:
"200":
description: Отмечено
"404":
description: Не найдено
# ─── Reports ─────────────────────────────────────────────────────────────────
/reports/orders:
get:
operationId: getOrdersReport
summary: Отчёт по заявкам
tags: [Reports]
description: Доступно для ролей admin и manager. Возвращает агрегированные данные по завершённым заявкам за период.
parameters:
- name: from
in: query
required: false
schema:
type: string
format: date
- name: to
in: query
required: false
schema:
type: string
format: date
- name: status
in: query
required: false
schema:
type: string
enum: [pending, assigned, in_transit, delivered, cancelled]
- name: driverId
in: query
required: false
schema:
type: string
format: uuid
- name: warehouseId
in: query
required: false
schema:
type: string
format: uuid
responses:
"200":
description: Отчёт сформирован
content:
application/json:
schema:
$ref: "#/components/schemas/ApiResponse"
"403":
description: Нет доступа
/reports/dashboard:
get:
operationId: getDashboard
summary: Дашборд менеджмента
description: Доступно для ролей admin и manager. Возвращает агрегированную статистику по выручке, заявкам и водителям.
tags: [Reports]
responses:
"200":
description: Данные дашборда
content:
application/json:
schema:
$ref: "#/components/schemas/ApiResponse"
"403":
description: Нет доступа

File diff suppressed because it is too large Load Diff

View File

@@ -57,6 +57,13 @@ type Config struct {
Issuer string `koanf:"issuer"` Issuer string `koanf:"issuer"`
Audience string `koanf:"audience"` Audience string `koanf:"audience"`
} `koanf:"jwt"` } `koanf:"jwt"`
Pricing struct {
BaseFee float64 `koanf:"baseFee"`
PerKm float64 `koanf:"perKm"`
PerKg float64 `koanf:"perKg"`
PerM3 float64 `koanf:"perM3"`
} `koanf:"pricing"`
} }
func NewConfig(ctx context.Context, configPath string) (*Config, error) { func NewConfig(ctx context.Context, configPath string) (*Config, error) {
@@ -152,6 +159,18 @@ func (c *Config) setDefaults() {
if c.Redis.Addr == "" { if c.Redis.Addr == "" {
c.Redis.Addr = "localhost:6379" c.Redis.Addr = "localhost:6379"
} }
if c.Pricing.BaseFee == 0 {
c.Pricing.BaseFee = 500.0
}
if c.Pricing.PerKm == 0 {
c.Pricing.PerKm = 25.0
}
if c.Pricing.PerKg == 0 {
c.Pricing.PerKg = 3.0
}
if c.Pricing.PerM3 == 0 {
c.Pricing.PerM3 = 150.0
}
} }
func (c *Config) parseDurations() error { func (c *Config) parseDurations() error {

View File

@@ -1,13 +1,195 @@
package handler package handler
import "net/http" import (
"encoding/json"
"errors"
"log/slog"
"net/http"
"time"
func (s *Server) ListDrivers(w http.ResponseWriter, r *http.Request) {} "github.com/anxi0uz/logiflow/internal/api"
"github.com/anxi0uz/logiflow/internal/models"
storage "github.com/anxi0uz/logiflow/pkg"
"github.com/google/uuid"
"github.com/gosimple/slug"
"github.com/huandu/go-sqlbuilder"
"golang.org/x/crypto/bcrypt"
)
func (s *Server) CreateDriver(w http.ResponseWriter, r *http.Request) {} func (s *Server) ListDrivers(w http.ResponseWriter, r *http.Request, params api.ListDriversParams) {}
func (s *Server) GetDriver(w http.ResponseWriter, r *http.Request, slug string) {} func (s *Server) CreateDriver(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
claims := ctx.Value("user").(*Claims)
if claims.Role != "admin" {
slog.WarnContext(ctx, "unusual try from not allowed role", slog.String("Role", claims.Role), slog.String("id", claims.ID.String()))
s.JSON(w, r, http.StatusForbidden, MsgForbidden, RespError)
return
}
var req api.DriverCreate
func (s *Server) UpdateDriver(w http.ResponseWriter, r *http.Request, slug string) {} if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
slog.ErrorContext(ctx, "Invalid request body", slog.String("error", err.Error()))
s.JSON(w, r, http.StatusBadRequest, MsgInvalidBody, RespError)
return
}
passwordHash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if err != nil {
slog.ErrorContext(ctx, "error while generating passwordhash", slog.String("error", err.Error()))
s.JSON(w, r, http.StatusInternalServerError, MsgInternalError, RespError)
return
}
now := time.Now()
userid := uuid.New()
user := models.User{
ID: userid,
Slug: s.GenerateUserSlug(req.FullName, userid),
CreatedAt: now,
UpdatedAt: now,
Role: "driver",
Email: string(req.Email),
PasswordHash: string(passwordHash),
FullName: req.FullName,
}
func (s *Server) DeleteDriver(w http.ResponseWriter, r *http.Request, slug string) {} tx, err := s.DB.Begin(ctx)
if err != nil {
slog.ErrorContext(ctx, "Unable to open transaction", slog.String("error", err.Error()))
s.JSON(w, r, http.StatusInternalServerError, MsgInternalError, RespError)
return
}
defer tx.Rollback(ctx)
if err := storage.Create(ctx, "users", user, tx); err != nil {
slog.ErrorContext(ctx, "Unable to create user", slog.String("error", err.Error()), slog.Any("user", user))
s.JSON(w, r, http.StatusInternalServerError, MsgInternalError, RespError)
return
}
id := uuid.New()
driver := models.Driver{
ID: id,
UserID: userid,
VehicleID: req.VehicleId,
LicenseNumber: req.LicenseNumber,
LicenseExpiry: req.LicenseExpiry.Time,
Rating: 0,
Slug: slug.Make(req.FullName + " " + req.LicenseNumber),
}
if err := storage.Create(ctx, "drivers", driver, tx); err != nil {
slog.ErrorContext(ctx, "Unable to create driver", slog.String("error", err.Error()), slog.Any("driver", driver))
s.JSON(w, r, http.StatusInternalServerError, MsgInternalError, RespError)
return
}
if err := tx.Commit(ctx); err != nil {
slog.ErrorContext(ctx, "Error while commiting transaction", slog.String("error", err.Error()))
s.JSON(w, r, http.StatusInternalServerError, MsgInternalError, RespError)
return
}
s.JSON(w, r, http.StatusCreated, driver, "driver")
}
func (s *Server) UpdateMyDriverStatus(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
claims, ok := ctx.Value("user").(*Claims)
if !ok {
slog.ErrorContext(ctx, "Unable to convert claims", slog.Any("claims", ctx.Value("user")))
s.JSON(w, r, http.StatusInternalServerError, MsgInternalError, RespError)
return
}
var req api.DriverStatusUpdate
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
s.JSON(w, r, http.StatusBadRequest, MsgInvalidBody, RespError)
return
}
if err := storage.Update(ctx, "drivers", models.Driver{Status: string(req.Status)}, s.DB, func(ub *sqlbuilder.UpdateBuilder) {
ub.Where(ub.Equal("user_id", claims.ID))
}); err != nil {
if errors.Is(err, storage.ErrNotFound) {
slog.ErrorContext(ctx, "No driver with that user id not found", slog.String("user id", claims.ID.String()))
s.JSON(w, r, http.StatusNotFound, "driver not found", RespNotFound)
return
}
s.JSON(w, r, http.StatusInternalServerError, MsgInternalError, RespError)
return
}
s.JSON(w, r, http.StatusOK, "status updated", RespSuccess)
}
func (s *Server) GetDriver(w http.ResponseWriter, r *http.Request, slug string) {
ctx := r.Context()
driver, err := storage.GetOne[models.Driver](ctx, s.DB, "drivers", func(sb *sqlbuilder.SelectBuilder) {
sb.Where(sb.Equal("slug", slug))
})
if err != nil {
if errors.Is(err, storage.ErrNotFound) {
slog.ErrorContext(ctx, "No driver found with that slug", slog.String("slug", slug))
s.JSON(w, r, http.StatusNotFound, MsgNotFound, RespNotFound)
return
}
slog.ErrorContext(ctx, "Error while finding driver with that slug", slog.String("slug", slug), slog.String("error", err.Error()))
s.JSON(w, r, http.StatusInternalServerError, MsgInternalError, RespError)
return
}
s.JSON(w, r, http.StatusOK, driver, RespSuccess)
}
func (s *Server) UpdateDriver(w http.ResponseWriter, r *http.Request, slug string) {
ctx := r.Context()
var req api.DriverUpdate
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
slog.ErrorContext(ctx, "Invalid request body", slog.String("error", err.Error()))
s.JSON(w, r, http.StatusBadRequest, MsgInternalError, RespError)
return
}
driver := models.Driver{
LicenseNumber: *req.LicenseNumber,
LicenseExpiry: req.LicenseExpiry.Time,
Status: string(*req.Status),
VehicleID: req.VehicleId,
}
if err := storage.Update(ctx, "drivers", driver, s.DB, func(sb *sqlbuilder.UpdateBuilder) {
sb.Where(sb.Equal("slug", slug))
}); err != nil {
if errors.Is(err, storage.ErrNotFound) {
slog.ErrorContext(ctx, "no drivers found with that slug", slog.String("slug", slug), slog.String("error", err.Error()))
s.JSON(w, r, http.StatusNotFound, MsgNotFound, RespNotFound)
return
}
slog.ErrorContext(ctx, "Error while updating driver with that slug", slog.String("slug", slug), slog.String("error", err.Error()))
s.JSON(w, r, http.StatusInternalServerError, MsgInternalError, RespError)
return
}
s.JSON(w, r, http.StatusOK, slug, RespSuccess)
}
func (s *Server) DeleteDriver(w http.ResponseWriter, r *http.Request, slug string) {
ctx := r.Context()
err := storage.Delete[models.Driver](ctx, "drivers", s.DB, func(sb *sqlbuilder.DeleteBuilder) {
sb.Where(sb.Equal("slug", slug))
})
if err != nil {
if errors.Is(err, storage.ErrNotFound) {
s.JSON(w, r, http.StatusNotFound, "No driver with that slug found", RespNotFound)
return
}
slog.ErrorContext(ctx, "unable to delete vehicle with that slug", slog.String("slug", slug), slog.String("error", err.Error()))
s.JSON(w, r, http.StatusInternalServerError, MsgInternalError, RespError)
return
}
s.JSON(w, r, http.StatusOK, slug, "deleted")
}

View File

@@ -0,0 +1,14 @@
package handler
import (
"net/http"
"github.com/anxi0uz/logiflow/internal/api"
openapi_types "github.com/oapi-codegen/runtime/types"
)
func (s *Server) ListNotifications(w http.ResponseWriter, r *http.Request, params api.ListNotificationsParams) {
}
func (s *Server) MarkNotificationRead(w http.ResponseWriter, r *http.Request, id openapi_types.UUID) {
}

View File

@@ -3,10 +3,11 @@ package handler
import ( import (
"net/http" "net/http"
"github.com/anxi0uz/logiflow/internal/api"
openapi_types "github.com/oapi-codegen/runtime/types" openapi_types "github.com/oapi-codegen/runtime/types"
) )
func (s *Server) ListOrders(w http.ResponseWriter, r *http.Request) {} func (s *Server) ListOrders(w http.ResponseWriter, r *http.Request, params api.ListOrdersParams) {}
func (s *Server) CreateOrder(w http.ResponseWriter, r *http.Request) {} func (s *Server) CreateOrder(w http.ResponseWriter, r *http.Request) {}
@@ -15,3 +16,8 @@ func (s *Server) GetOrder(w http.ResponseWriter, r *http.Request, id openapi_typ
func (s *Server) CancelOrder(w http.ResponseWriter, r *http.Request, id openapi_types.UUID) {} func (s *Server) CancelOrder(w http.ResponseWriter, r *http.Request, id openapi_types.UUID) {}
func (s *Server) UpdateOrderStatus(w http.ResponseWriter, r *http.Request, id openapi_types.UUID) {} func (s *Server) UpdateOrderStatus(w http.ResponseWriter, r *http.Request, id openapi_types.UUID) {}
func (s *Server) GetOrdersReport(w http.ResponseWriter, r *http.Request, params api.GetOrdersReportParams) {
}
func (s *Server) GetDashboard(w http.ResponseWriter, r *http.Request) {}

View File

@@ -26,6 +26,19 @@ type ctxKey string
const ( const (
requestIDKey ctxKey = "X-Request-ID" requestIDKey ctxKey = "X-Request-ID"
tokenKey ctxKey = "Authorization" tokenKey ctxKey = "Authorization"
// response messages
MsgInternalError = "Internal server error"
MsgInvalidBody = "Invalid request body"
MsgNotFound = "Not found"
MsgUnauthorized = "Unauthorized"
MsgMissingToken = "Missing token"
MsgForbidden = "Forbidden"
// response types
RespError = "error"
RespSuccess = "success"
RespNotFound = "not found"
) )
type responseOptions struct { type responseOptions struct {
@@ -112,7 +125,7 @@ func (s *Server) AuthMiddleware(next http.Handler) http.Handler {
tokenStr := r.Header.Get("Authorization") tokenStr := r.Header.Get("Authorization")
if tokenStr == "" { if tokenStr == "" {
s.JSON(w, r, http.StatusUnauthorized, "missing token", "error") s.JSON(w, r, http.StatusUnauthorized, MsgMissingToken, RespError)
return return
} }
@@ -121,7 +134,7 @@ func (s *Server) AuthMiddleware(next http.Handler) http.Handler {
claims, err := s.validateAccessToken(r.Context(), tokenStr) claims, err := s.validateAccessToken(r.Context(), tokenStr)
if err != nil { if err != nil {
slog.WarnContext(r.Context(), "Токен не прошел валидацию", slog.String("Token", tokenStr), slog.String("error", err.Error())) slog.WarnContext(r.Context(), "Токен не прошел валидацию", slog.String("Token", tokenStr), slog.String("error", err.Error()))
s.JSON(w, r, http.StatusUnauthorized, "токен не прошёл валидацию", "error") s.JSON(w, r, http.StatusUnauthorized, MsgUnauthorized, RespError)
return return
} }

View File

@@ -33,7 +33,7 @@ func (s *Server) AuthLogin(w http.ResponseWriter, r *http.Request) {
var req api.LoginRequest var req api.LoginRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
s.JSON(w, r, http.StatusBadRequest, "Ошибка при получении данных", "error") s.JSON(w, r, http.StatusBadRequest, MsgInvalidBody, RespError)
return return
} }
@@ -65,32 +65,32 @@ func (s *Server) AuthLogout(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie("refresh_token") cookie, err := r.Cookie("refresh_token")
if err != nil { if err != nil {
s.JSON(w, r, http.StatusUnauthorized, "Missing refresh token", "error") s.JSON(w, r, http.StatusUnauthorized, MsgMissingToken, RespError)
return return
} }
refreshStr := cookie.Value refreshStr := cookie.Value
if refreshStr == "" { if refreshStr == "" {
s.JSON(w, r, http.StatusUnauthorized, "Empty refresh token", "error") s.JSON(w, r, http.StatusUnauthorized, MsgUnauthorized, RespError)
return return
} }
refreshKey := "refresh_token:" + refreshStr refreshKey := "refresh_token:" + refreshStr
if err := s.Redis.Del(ctx, refreshKey).Err(); err != nil { if err := s.Redis.Del(ctx, refreshKey).Err(); err != nil {
slog.ErrorContext(ctx, "Error while removing refresh token from redis", slog.String("token", refreshStr), slog.String("error", err.Error())) slog.ErrorContext(ctx, "Error while removing refresh token from redis", slog.String("token", refreshStr), slog.String("error", err.Error()))
s.JSON(w, r, http.StatusUnauthorized, "Internal server error", "error") s.JSON(w, r, http.StatusInternalServerError, MsgInternalError, RespError)
return return
} }
tokenStr := r.Header.Get("Authorization") tokenStr := r.Header.Get("Authorization")
if tokenStr == "" { if tokenStr == "" {
s.JSON(w, r, http.StatusUnauthorized, "missing token", "error") s.JSON(w, r, http.StatusUnauthorized, MsgMissingToken, RespError)
return return
} }
tokenKey := "access_token:" + tokenStr tokenKey := "access_token:" + tokenStr
if err := s.Redis.Del(ctx, tokenKey).Err(); err != nil { if err := s.Redis.Del(ctx, tokenKey).Err(); err != nil {
slog.ErrorContext(ctx, "Error while removing access token from redis", slog.String("token", tokenStr), slog.String("error", err.Error())) slog.ErrorContext(ctx, "Error while removing access token from redis", slog.String("token", tokenStr), slog.String("error", err.Error()))
s.JSON(w, r, http.StatusUnauthorized, "Internal server error", "error") s.JSON(w, r, http.StatusInternalServerError, MsgInternalError, RespError)
return return
} }
} }
@@ -99,17 +99,17 @@ func (s *Server) AuthRefresh(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie("refresh_token") cookie, err := r.Cookie("refresh_token")
if err != nil { if err != nil {
s.JSON(w, r, http.StatusUnauthorized, "Missing refresh token", "error") s.JSON(w, r, http.StatusUnauthorized, MsgMissingToken, RespError)
return return
} }
refreshStr := cookie.Value refreshStr := cookie.Value
if refreshStr == "" { if refreshStr == "" {
s.JSON(w, r, http.StatusUnauthorized, "Empty refresh token", "error") s.JSON(w, r, http.StatusUnauthorized, MsgUnauthorized, RespError)
return return
} }
key := "refresh_token:" + refreshStr key := "refresh_token:" + refreshStr
if _, err := s.Redis.Get(ctx, key).Result(); err == redis.Nil { if _, err := s.Redis.Get(ctx, key).Result(); err == redis.Nil {
s.JSON(w, r, http.StatusUnauthorized, "No active refresh token", "error") s.JSON(w, r, http.StatusUnauthorized, MsgUnauthorized, RespError)
return return
} }
@@ -119,13 +119,13 @@ func (s *Server) AuthRefresh(w http.ResponseWriter, r *http.Request) {
if !ok { if !ok {
slog.ErrorContext(ctx, "Error parsing claims", slog.Any("claims", claims)) slog.ErrorContext(ctx, "Error parsing claims", slog.Any("claims", claims))
s.JSON(w, r, http.StatusInternalServerError, nil, "internal server error") s.JSON(w, r, http.StatusInternalServerError, MsgInternalError, RespError)
return return
} }
userID := claims.ID userID := claims.ID
if userID == uuid.Nil { if userID == uuid.Nil {
s.JSON(w, r, http.StatusUnauthorized, "Missing user id in token", "error") s.JSON(w, r, http.StatusUnauthorized, MsgUnauthorized, RespError)
return return
} }
@@ -134,7 +134,7 @@ func (s *Server) AuthRefresh(w http.ResponseWriter, r *http.Request) {
}) })
if err != nil { if err != nil {
slog.ErrorContext(ctx, "user with that id not found", slog.Any("id", userID.String()), "error", err.Error()) slog.ErrorContext(ctx, "user with that id not found", slog.Any("id", userID.String()), "error", err.Error())
s.JSON(w, r, http.StatusUnauthorized, "Invalid user id in token", "error") s.JSON(w, r, http.StatusUnauthorized, MsgUnauthorized, RespError)
return return
} }
s.issueTokens(w, r, user) s.issueTokens(w, r, user)
@@ -146,13 +146,13 @@ func (s *Server) AuthRegister(w http.ResponseWriter, r *http.Request) {
var req api.RegisterRequest var req api.RegisterRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
s.JSON(w, r, http.StatusBadRequest, "invalid request body", "error") s.JSON(w, r, http.StatusBadRequest, MsgInvalidBody, RespError)
return return
} }
passwordHash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) passwordHash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if err != nil { if err != nil {
s.JSON(w, r, http.StatusInternalServerError, "error while hashing password", "error") s.JSON(w, r, http.StatusInternalServerError, MsgInternalError, RespError)
return return
} }
@@ -165,7 +165,7 @@ func (s *Server) AuthRegister(w http.ResponseWriter, r *http.Request) {
Slug: s.GenerateUserSlug(req.FullName, uuid), Slug: s.GenerateUserSlug(req.FullName, uuid),
CreatedAt: now, CreatedAt: now,
UpdatedAt: now, UpdatedAt: now,
Role: string(req.Role), Role: "client",
Email: string(req.Email), Email: string(req.Email),
PasswordHash: string(passwordHash), PasswordHash: string(passwordHash),
FullName: req.FullName, FullName: req.FullName,
@@ -173,7 +173,7 @@ func (s *Server) AuthRegister(w http.ResponseWriter, r *http.Request) {
if err := storage.Create(ctx, "users", user, s.DB); err != nil { if err := storage.Create(ctx, "users", user, s.DB); err != nil {
slog.ErrorContext(ctx, "Error while creating user", slog.String("error", err.Error())) slog.ErrorContext(ctx, "Error while creating user", slog.String("error", err.Error()))
s.JSON(w, r, http.StatusInternalServerError, "Server error", "error") s.JSON(w, r, http.StatusInternalServerError, MsgInternalError, RespError)
return return
} }
s.issueTokens(w, r, &user) s.issueTokens(w, r, &user)
@@ -185,14 +185,14 @@ func (s *Server) DeleteMe(w http.ResponseWriter, r *http.Request) {
claims, ok := claimsValue.(*Claims) claims, ok := claimsValue.(*Claims)
if !ok { if !ok {
slog.ErrorContext(ctx, "Error while converting claims", slog.Any("claims", claimsValue)) slog.ErrorContext(ctx, "Error while converting claims", slog.Any("claims", claimsValue))
s.JSON(w, r, http.StatusInternalServerError, "Internal server error", "error") s.JSON(w, r, http.StatusInternalServerError, MsgInternalError, RespError)
return return
} }
userID := claims.ID userID := claims.ID
tx, err := s.DB.Begin(ctx) tx, err := s.DB.Begin(ctx)
if err != nil { if err != nil {
slog.ErrorContext(ctx, "Error while begining transaction", slog.String("error", err.Error())) slog.ErrorContext(ctx, "Error while begining transaction", slog.String("error", err.Error()))
s.JSON(w, r, http.StatusInternalServerError, "Internal server error", "error") s.JSON(w, r, http.StatusInternalServerError, MsgInternalError, RespError)
return return
} }
defer tx.Rollback(ctx) defer tx.Rollback(ctx)
@@ -202,7 +202,7 @@ func (s *Server) DeleteMe(w http.ResponseWriter, r *http.Request) {
}) })
if err != nil { if err != nil {
slog.ErrorContext(ctx, "Error while deleting user", slog.String("error", err.Error()), slog.String("id", userID.String())) slog.ErrorContext(ctx, "Error while deleting user", slog.String("error", err.Error()), slog.String("id", userID.String()))
s.JSON(w, r, http.StatusInternalServerError, "Internal server error", "error") s.JSON(w, r, http.StatusInternalServerError, MsgInternalError, RespError)
return return
} }
@@ -210,29 +210,29 @@ func (s *Server) DeleteMe(w http.ResponseWriter, r *http.Request) {
tokenKey := "access_token:" + jwt tokenKey := "access_token:" + jwt
if err := s.Redis.Del(ctx, tokenKey).Err(); err != nil { if err := s.Redis.Del(ctx, tokenKey).Err(); err != nil {
slog.ErrorContext(ctx, "Error while deleting access token from redis", slog.String("token", jwt)) slog.ErrorContext(ctx, "Error while deleting access token from redis", slog.String("token", jwt))
s.JSON(w, r, http.StatusInternalServerError, "Internal server error", "error") s.JSON(w, r, http.StatusInternalServerError, MsgInternalError, RespError)
return return
} }
cookie, err := r.Cookie("refresh_token") cookie, err := r.Cookie("refresh_token")
if err != nil { if err != nil {
s.JSON(w, r, http.StatusUnauthorized, "Missing refresh token", "error") s.JSON(w, r, http.StatusUnauthorized, MsgMissingToken, RespError)
return return
} }
refreshStr := cookie.Value refreshStr := cookie.Value
if refreshStr == "" { if refreshStr == "" {
s.JSON(w, r, http.StatusUnauthorized, "Empty refresh token", "error") s.JSON(w, r, http.StatusUnauthorized, MsgUnauthorized, RespError)
return return
} }
key := "refresh_token:" + refreshStr key := "refresh_token:" + refreshStr
if err := s.Redis.Del(ctx, key).Err(); err != nil { if err := s.Redis.Del(ctx, key).Err(); err != nil {
slog.ErrorContext(ctx, "Error while deleting refresh token from redis", slog.String("token", refreshStr)) slog.ErrorContext(ctx, "Error while deleting refresh token from redis", slog.String("token", refreshStr))
s.JSON(w, r, http.StatusInternalServerError, "Internal server error", "error") s.JSON(w, r, http.StatusInternalServerError, MsgInternalError, RespError)
return return
} }
if err := tx.Commit(ctx); err != nil { if err := tx.Commit(ctx); err != nil {
slog.ErrorContext(ctx, "Error while committing transaction", slog.String("error", err.Error())) slog.ErrorContext(ctx, "Error while committing transaction", slog.String("error", err.Error()))
s.JSON(w, r, http.StatusInternalServerError, "Internal server error", "error") s.JSON(w, r, http.StatusInternalServerError, MsgInternalError, RespError)
return return
} }
@@ -246,7 +246,7 @@ func (s *Server) GetMe(w http.ResponseWriter, r *http.Request) {
claims, ok := claimsValue.(*Claims) claims, ok := claimsValue.(*Claims)
if !ok { if !ok {
slog.ErrorContext(ctx, "Error while converting claims", slog.Any("claims", claimsValue)) slog.ErrorContext(ctx, "Error while converting claims", slog.Any("claims", claimsValue))
s.JSON(w, r, http.StatusInternalServerError, "Internal server error", "error") s.JSON(w, r, http.StatusInternalServerError, MsgInternalError, RespError)
return return
} }
@@ -258,13 +258,15 @@ func (s *Server) GetMe(w http.ResponseWriter, r *http.Request) {
slog.ErrorContext(ctx, "No user was found with that id", slog.ErrorContext(ctx, "No user was found with that id",
slog.String("id", userID.String()), slog.String("id", userID.String()),
slog.String("error", err.Error())) slog.String("error", err.Error()))
s.JSON(w, r, http.StatusInternalServerError, "invalid user id", "error") s.JSON(w, r, http.StatusInternalServerError, MsgInternalError, RespError)
return return
} }
s.JSON(w, r, http.StatusOK, user, "ok") 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) {}
func (s *Server) GetMyTrips(w http.ResponseWriter, r *http.Request) {}
func (s *Server) issueTokens(w http.ResponseWriter, r *http.Request, user *models.User) { func (s *Server) issueTokens(w http.ResponseWriter, r *http.Request, user *models.User) {
access, err := s.generateAccessToken(user, s.Config.RedisAccessTokenDur()) access, err := s.generateAccessToken(user, s.Config.RedisAccessTokenDur())
if err != nil { if err != nil {
@@ -274,7 +276,7 @@ func (s *Server) issueTokens(w http.ResponseWriter, r *http.Request, user *model
refresh, err := s.generateRefreshToken() refresh, err := s.generateRefreshToken()
if err != nil { if err != nil {
slog.ErrorContext(r.Context(), "generate refresh token failed", slog.String("error", err.Error())) slog.ErrorContext(r.Context(), "generate refresh token failed", slog.String("error", err.Error()))
s.JSON(w, r, http.StatusInternalServerError, "Failure during generating tokens", "error") s.JSON(w, r, http.StatusInternalServerError, MsgInternalError, RespError)
return return
} }
@@ -283,14 +285,14 @@ func (s *Server) issueTokens(w http.ResponseWriter, r *http.Request, user *model
err = s.Redis.Set(r.Context(), key, "valid", s.Config.RedisAccessTokenDur()).Err() err = s.Redis.Set(r.Context(), key, "valid", s.Config.RedisAccessTokenDur()).Err()
if err != nil { if err != nil {
slog.ErrorContext(r.Context(), "Failed to set access token in redis", slog.String("error", err.Error())) slog.ErrorContext(r.Context(), "Failed to set access token in redis", slog.String("error", err.Error()))
s.JSON(w, r, http.StatusInternalServerError, "Server error", "error") s.JSON(w, r, http.StatusInternalServerError, MsgInternalError, RespError)
return return
} }
err = s.Redis.Set(r.Context(), refreshkey, "valid", s.Config.RedisRefreshTokenDur()).Err() err = s.Redis.Set(r.Context(), refreshkey, "valid", s.Config.RedisRefreshTokenDur()).Err()
if err != nil { if err != nil {
slog.ErrorContext(r.Context(), "Failed to set refresh token in redis", slog.String("error", err.Error())) slog.ErrorContext(r.Context(), "Failed to set refresh token in redis", slog.String("error", err.Error()))
s.JSON(w, r, http.StatusInternalServerError, "Server error", "error") s.JSON(w, r, http.StatusInternalServerError, MsgInternalError, RespError)
return return
} }

View File

@@ -1,13 +1,155 @@
package handler package handler
import "net/http" import (
"encoding/json"
"errors"
"log/slog"
"net/http"
func (s *Server) ListVehicles(w http.ResponseWriter, r *http.Request) {} "github.com/anxi0uz/logiflow/internal/api"
"github.com/anxi0uz/logiflow/internal/models"
storage "github.com/anxi0uz/logiflow/pkg"
"github.com/google/uuid"
"github.com/gosimple/slug"
"github.com/huandu/go-sqlbuilder"
)
func (s *Server) CreateVehicle(w http.ResponseWriter, r *http.Request) {} func (s *Server) ListVehicles(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
func (s *Server) GetVehicle(w http.ResponseWriter, r *http.Request, slug string) {} vehicles, err := storage.GetAll[models.Vehicle](ctx, "vehicles", s.DB)
if err != nil {
slog.ErrorContext(ctx, "Unable to get all vehicles", slog.String("error", err.Error()))
s.JSON(w, r, http.StatusInternalServerError, MsgInternalError, RespError)
return
}
s.JSON(w, r, http.StatusOK, vehicles, RespSuccess)
}
func (s *Server) UpdateVehicle(w http.ResponseWriter, r *http.Request, slug string) {} func (s *Server) CreateVehicle(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
func (s *Server) DeleteVehicle(w http.ResponseWriter, r *http.Request, slug string) {} var req api.VehicleCreate
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
slog.ErrorContext(ctx, "Invalid request body", slog.String("error", err.Error()))
s.JSON(w, r, http.StatusBadRequest, MsgInvalidBody, RespError)
return
}
id := uuid.New()
vehicle := models.Vehicle{
ID: id,
PlateNumber: req.PlateNumber,
Slug: slug.Make(req.PlateNumber),
}
if req.Brand != nil {
vehicle.Brand = *req.Brand
}
if req.Model != nil {
vehicle.Model = *req.Model
}
if req.Year != nil {
vehicle.Year = *req.Year
}
if req.CapacityKg != nil {
vehicle.CapacityKg = float64(*req.CapacityKg)
}
if req.CapacityM3 != nil {
vehicle.CapacityM3 = float64(*req.CapacityM3)
}
if err := storage.Create(ctx, "vehicles", vehicle, s.DB); err != nil {
slog.ErrorContext(ctx, "Unable to create vehicle", slog.Any("vehicle", vehicle))
s.JSON(w, r, http.StatusInternalServerError, MsgInternalError, RespError)
return
}
s.JSON(w, r, http.StatusCreated, vehicle, "vehicle")
}
func (s *Server) GetVehicle(w http.ResponseWriter, r *http.Request, slug string) {
ctx := r.Context()
vehicle, err := storage.GetOne[models.Vehicle](ctx, s.DB, "vehicles", func(sb *sqlbuilder.SelectBuilder) {
sb.Where(sb.Equal("slug", slug))
})
if err != nil {
if errors.Is(err, storage.ErrNotFound) {
s.JSON(w, r, http.StatusNotFound, "vehicle not found with that slug", RespNotFound)
return
}
slog.ErrorContext(ctx, "no vehicle found in db with that slug", slog.String("slug", slug), slog.String("error", err.Error()))
s.JSON(w, r, http.StatusInternalServerError, MsgInternalError, RespError)
return
}
s.JSON(w, r, http.StatusOK, vehicle, "vehicle")
}
func (s *Server) UpdateVehicle(w http.ResponseWriter, r *http.Request, slug string) {
ctx := r.Context()
var req api.VehicleUpdate
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
slog.ErrorContext(ctx, "Invalid request body", slog.String("error", err.Error()))
s.JSON(w, r, http.StatusBadRequest, MsgInvalidBody, RespError)
return
}
vehicle, err := storage.GetOne[models.Vehicle](ctx, s.DB, "vehicles", func(sb *sqlbuilder.SelectBuilder) {
sb.Where(sb.Equal("slug", slug))
})
if err != nil {
if errors.Is(err, storage.ErrNotFound) {
s.JSON(w, r, http.StatusNotFound, "vehicle not found with that slug", RespNotFound)
return
}
slog.ErrorContext(ctx, "no vehicle found in db with that slug", slog.String("slug", slug), slog.String("error", err.Error()))
s.JSON(w, r, http.StatusInternalServerError, MsgInternalError, RespError)
return
}
if req.Status != nil {
vehicle.Status = string(*req.Status)
}
if req.Brand != nil {
vehicle.Brand = *req.Brand
}
if req.Model != nil {
vehicle.Model = *req.Model
}
if req.Year != nil {
vehicle.Year = *req.Year
}
if req.CapacityKg != nil {
vehicle.CapacityKg = float64(*req.CapacityKg)
}
if req.CapacityM3 != nil {
vehicle.CapacityM3 = float64(*req.CapacityM3)
}
if err := storage.Update(ctx, "vehicles", *vehicle, s.DB, func(ub *sqlbuilder.UpdateBuilder) {
ub.Where(ub.Equal("slug", slug))
}); err != nil {
slog.ErrorContext(ctx, "failed to update vehicle", slog.String("slug", slug), slog.String("error", err.Error()))
s.JSON(w, r, http.StatusInternalServerError, MsgInternalError, RespError)
return
}
s.JSON(w, r, http.StatusOK, vehicle, "vehicle")
}
func (s *Server) DeleteVehicle(w http.ResponseWriter, r *http.Request, slug string) {
ctx := r.Context()
err := storage.Delete[models.Vehicle](ctx, "vehicles", s.DB, func(sb *sqlbuilder.DeleteBuilder) {
sb.Where(sb.Equal("slug", slug))
})
if err != nil {
if errors.Is(err, storage.ErrNotFound) {
s.JSON(w, r, http.StatusNotFound, "no vehicle with that slug found", RespNotFound)
return
}
slog.ErrorContext(ctx, "unable to delete vehicle with that slug", slog.String("slug", slug), slog.String("error", err.Error()))
s.JSON(w, r, http.StatusInternalServerError, MsgInternalError, RespError)
return
}
s.JSON(w, r, http.StatusOK, slug, "deleted")
}

View File

@@ -0,0 +1,16 @@
package models
import (
"time"
"github.com/google/uuid"
)
type Notification struct {
ID uuid.UUID `db:"id"`
UserID uuid.UUID `db:"user_id"`
Title string `db:"title"`
Body *string `db:"body"`
IsRead bool `db:"is_read"`
CreatedAt time.Time `db:"created_at"`
}

View File

@@ -1,9 +1,19 @@
-- +goose Up -- +goose Up
-- +goose StatementBegin -- +goose StatementBegin
SELECT 'up SQL query'; CREATE TABLE notifications (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
title TEXT NOT NULL,
body TEXT,
is_read BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_notifications_user_id ON notifications(user_id);
CREATE INDEX idx_notifications_user_unread ON notifications(user_id) WHERE is_read = FALSE;
-- +goose StatementEnd -- +goose StatementEnd
-- +goose Down -- +goose Down
-- +goose StatementBegin -- +goose StatementBegin
SELECT 'down SQL query'; DROP TABLE IF EXISTS notifications;
-- +goose StatementEnd -- +goose StatementEnd

View File

@@ -23,8 +23,6 @@ func GetAll[T any](ctx context.Context, table string, db Querier, opts ...func(*
sb.From(table) sb.From(table)
sb.OrderByDesc("created_at")
for _, opt := range opts { for _, opt := range opts {
opt(sb) opt(sb)
} }