Compare commits

...

11 Commits

Author SHA1 Message Date
Egor Pozharov
d1efebbb34 add WebSocket support for real-time delivery updates with JWT authentication and automatic reconnection
Some checks failed
Build and Push Docker Images / build-backend (push) Has been cancelled
Build and Push Docker Images / build-frontend (push) Has been cancelled
2026-05-21 15:52:05 +06:00
Egor Pozharov
c87aea47ce align fix
Some checks failed
Build and Push Docker Images / build-backend (push) Has been cancelled
Build and Push Docker Images / build-frontend (push) Has been cancelled
2026-05-12 15:18:14 +06:00
Egor Pozharov
e6f1d6198f align fix 2026-05-12 15:17:23 +06:00
Egor Pozharov
dc85b62bdb add creation date display with CalendarPlus icon to DeliveryCard header
Some checks failed
Build and Push Docker Images / build-backend (push) Has been cancelled
Build and Push Docker Images / build-frontend (push) Has been cancelled
2026-05-12 15:08:51 +06:00
Egor Pozharov
48a32d50fd add commit SHA build argument to frontend Docker builds in pre-push hook and Makefile, and introduce version bumping targets for release workflow
Some checks failed
Build and Push Docker Images / build-backend (push) Has been cancelled
Build and Push Docker Images / build-frontend (push) Has been cancelled
2026-05-05 14:40:27 +06:00
Egor Pozharov
c39bde0b10 add warehouse_request_source and warehouse_request_source_2 fields to deliveries table with validation and normalization logic
Some checks failed
Build and Push Docker Images / build-backend (push) Has been cancelled
Build and Push Docker Images / build-frontend (push) Has been cancelled
2026-05-04 17:32:03 +06:00
Egor Pozharov
f9c54b5172 add build version tracking with commit SHA, version number, and build timestamp to frontend
Some checks failed
Build and Push Docker Images / build-backend (push) Has been cancelled
Build and Push Docker Images / build-frontend (push) Has been cancelled
2026-05-04 16:08:37 +06:00
Egor Pozharov
a3929bec8d implement account lockout after 3 failed login attempts with 5-minute cooldown period
Some checks failed
Build and Push Docker Images / build-backend (push) Has been cancelled
Build and Push Docker Images / build-frontend (push) Has been cancelled
2026-04-29 17:00:37 +06:00
Egor Pozharov
459b60c9aa remove manual update prompt UI and enable automatic silent service worker updates
Some checks failed
Build and Push Docker Images / build-backend (push) Has been cancelled
Build and Push Docker Images / build-frontend (push) Has been cancelled
2026-04-21 16:26:01 +06:00
Egor Pozharov
4110083019 refine nginx caching strategy and add service worker update prompt with periodic checks
Some checks failed
Build and Push Docker Images / build-backend (push) Has been cancelled
Build and Push Docker Images / build-frontend (push) Has been cancelled
2026-04-21 12:55:01 +06:00
Egor Pozharov
60dea22ced add git hooks for commit validation, auto-suggestions, and Docker build/push automation on git push 2026-04-21 12:54:39 +06:00
46 changed files with 1153 additions and 276 deletions

View File

@@ -43,6 +43,8 @@ jobs:
with:
context: ./frontend
push: true
build-args: |
GITEA_SHA=${{ gitea.sha }}
tags: |
gitea.gitea.chedius.ru/${{ gitea.repository_owner }}/delivery-tracker/frontend:latest
gitea.gitea.chedius.ru/${{ gitea.repository_owner }}/delivery-tracker/frontend:${{ gitea.sha }}

27
.githooks/commit-msg Executable file
View File

@@ -0,0 +1,27 @@
#!/bin/bash
# Commit message hook: validate commit message format
COMMIT_MSG_FILE=$1
MSG=$(head -n1 "$COMMIT_MSG_FILE")
# Skip validation for merge commits
if echo "$MSG" | grep -q "^Merge"; then
exit 0
fi
# Check message length
if [ ${#MSG} -lt 5 ]; then
echo "❌ Commit message too short (min 5 characters)"
exit 1
fi
# Optional: enforce conventional commits format
# Uncomment if you want to enforce prefixes like "feat:", "fix:", etc.
# if ! echo "$MSG" | grep -qE "^(feat|fix|docs|style|refactor|test|chore|backend|frontend|deploy)(\(.+\))?:"; then
# echo "❌ Commit message should follow format: type: description"
# echo " Allowed types: feat, fix, docs, style, refactor, test, chore, backend, frontend, deploy"
# exit 1
# fi
echo "✅ Commit message OK"
exit 0

17
.githooks/post-commit Executable file
View File

@@ -0,0 +1,17 @@
#!/bin/bash
# Post-commit hook: show info after successful commit
COMMIT_HASH=$(git rev-parse --short HEAD)
COMMIT_MSG=$(git log -1 --pretty=%s)
BRANCH=$(git rev-parse --abbrev-ref HEAD)
echo ""
echo "✅ Commit created: $COMMIT_HASH"
echo "📝 Message: $COMMIT_MSG"
echo "🌿 Branch: $BRANCH"
echo ""
# Remind to push if needed
if [ "$BRANCH" = "main" ] || [ "$BRANCH" = "master" ]; then
echo "💡 Tip: Run 'git push' to trigger deployment"
fi

38
.githooks/pre-push Executable file
View File

@@ -0,0 +1,38 @@
#!/bin/bash
# Pre-push hook: build and push Docker images (blocks until complete)
# This ensures push only succeeds after successful build
REGISTRY="gitea.chedius.ru/chedius"
PLATFORM="linux/amd64"
COMMIT_SHA=$(git rev-parse --short=12 HEAD 2>/dev/null || echo dev)
echo "🔨 Building Docker images..."
# Build backend
docker build --platform $PLATFORM -t $REGISTRY/delivery-tracker/backend:latest ./backend || {
echo "❌ Backend build failed"
exit 1
}
# Build frontend
docker build --platform $PLATFORM --build-arg GITEA_SHA=$COMMIT_SHA -t $REGISTRY/delivery-tracker/frontend:latest ./frontend || {
echo "❌ Frontend build failed"
exit 1
}
echo "📤 Pushing Docker images..."
# Push backend
docker push $REGISTRY/delivery-tracker/backend:latest || {
echo "❌ Backend push failed"
exit 1
}
# Push frontend
docker push $REGISTRY/delivery-tracker/frontend:latest || {
echo "❌ Frontend push failed"
exit 1
}
echo "✅ Docker images built and pushed successfully"
echo "📡 Git push will now proceed..."

46
.githooks/prepare-commit-msg Executable file
View File

@@ -0,0 +1,46 @@
#!/bin/bash
# Prepare commit message hook: auto-generate commit message based on changes
COMMIT_MSG_FILE=$1
COMMIT_SOURCE=$2
# Only suggest message for regular commits (not merge, squash, etc.)
if [ -z "$COMMIT_SOURCE" ] || [ "$COMMIT_SOURCE" = "message" ]; then
# Get list of changed files
CHANGED_FILES=$(git diff --cached --name-only --diff-filter=ACM)
if [ -z "$CHANGED_FILES" ]; then
exit 0
fi
# Determine commit type based on changed files
if echo "$CHANGED_FILES" | grep -q "backend/"; then
PREFIX="backend:"
elif echo "$CHANGED_FILES" | grep -q "frontend/"; then
PREFIX="frontend:"
elif echo "$CHANGED_FILES" | grep -q "\.github\|\.gitea\|deploy\|docker"; then
PREFIX="deploy:"
else
PREFIX="chore:"
fi
# Count files
FILE_COUNT=$(echo "$CHANGED_FILES" | wc -l | tr -d ' ')
# Generate suggested message
if [ "$FILE_COUNT" -eq 1 ]; then
FILENAME=$(basename "$CHANGED_FILES")
SUGGESTED_MSG="$PREFIX update $FILENAME"
else
SUGGESTED_MSG="$PREFIX update $FILE_COUNT files"
fi
# If message file is empty or has default template, add suggestion
if [ ! -s "$COMMIT_MSG_FILE" ] || ! grep -v '^#' "$COMMIT_MSG_FILE" | grep -q '[^[:space:]]'; then
echo "$SUGGESTED_MSG" > "$COMMIT_MSG_FILE"
echo "" >> "$COMMIT_MSG_FILE"
echo "# Suggested commit message generated based on changes" >> "$COMMIT_MSG_FILE"
echo "# Changed files:" >> "$COMMIT_MSG_FILE"
echo "$CHANGED_FILES" | sed 's/^/# /' >> "$COMMIT_MSG_FILE"
fi
fi

View File

@@ -1,15 +1,17 @@
# Delivery Tracker - Local Build & Deploy
REGISTRY = gitea.chedius.ru/chedius
PLATFORM = linux/amd64
COMMIT_SHA := $(shell git rev-parse --short=12 HEAD 2>/dev/null || echo dev)
VERSION_PART ?= patch
# Build and push both services
.PHONY: all build push deploy
.PHONY: all build push deploy install-hooks version version-patch version-minor version-major check-release-message release
all: build push
build:
docker build --platform $(PLATFORM) -t $(REGISTRY)/delivery-tracker/backend:latest ./backend
docker build --platform $(PLATFORM) -t $(REGISTRY)/delivery-tracker/frontend:latest ./frontend
docker build --platform $(PLATFORM) --build-arg GITEA_SHA=$(COMMIT_SHA) -t $(REGISTRY)/delivery-tracker/frontend:latest ./frontend
push:
docker push $(REGISTRY)/delivery-tracker/backend:latest
@@ -30,11 +32,30 @@ update-server:
docker pull $(REGISTRY)/delivery-tracker/frontend:latest
docker-compose up -d --force-recreate backend frontend
# Full workflow: commit, build, push
release:
# Install git hooks for automation
install-hooks:
git config core.hooksPath .githooks
chmod +x .githooks/*
@echo "✅ Git hooks installed from .githooks/"
version:
@node -p "require('./frontend/package.json').version"
version-patch:
cd frontend && npm version patch --no-git-tag-version
version-minor:
cd frontend && npm version minor --no-git-tag-version
version-major:
cd frontend && npm version major --no-git-tag-version
check-release-message:
@if [ -z "$(MSG)" ]; then echo "Usage: make release MSG='commit message'"; exit 1; fi
# Full release: commit, push, and auto-build via hook
release: check-release-message version-$(VERSION_PART)
git add -A
git commit -m "$(MSG)" || true
git push
$(MAKE) build push
@echo "Released! Watchtower will deploy within 60 seconds."
@echo "✅ Released! Watchtower will deploy within 60 seconds."

View File

@@ -10,6 +10,7 @@ import (
"github.com/chedius/delivery-tracker/internal/auth"
db "github.com/chedius/delivery-tracker/internal/db/sqlc"
"github.com/chedius/delivery-tracker/internal/delivery"
"github.com/chedius/delivery-tracker/internal/ws"
"github.com/gin-contrib/cors"
"github.com/gin-gonic/gin"
"github.com/jackc/pgx/v5/pgxpool"
@@ -42,7 +43,8 @@ func main() {
queries := db.New(pool)
_, authHandler := initAuth(queries)
h := delivery.NewHandler(queries)
hub := ws.NewHub()
h := delivery.NewHandler(queries, hub)
r := gin.Default()
@@ -61,6 +63,7 @@ func main() {
r.POST("/api/auth/register", authHandler.Register)
r.POST("/api/auth/login", authHandler.Login)
r.GET("/api/ws", ws.HandleWS(hub, []byte(os.Getenv("JWT_SECRET"))))
authorized := r.Group("/api")
authorized.Use(auth.AuthMiddleware([]byte(os.Getenv("JWT_SECRET"))))

View File

@@ -14,6 +14,8 @@ require (
require github.com/golang-jwt/jwt/v5 v5.3.1
require github.com/gorilla/websocket v1.5.3
require (
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic v1.15.0 // indirect

View File

@@ -36,6 +36,8 @@ github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
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/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=

View File

@@ -1,6 +1,9 @@
package auth
import "errors"
import (
"errors"
"time"
)
var (
ErrInvalidCredentials = errors.New("invalid credentials")
@@ -10,3 +13,11 @@ var (
ErrCredentialsEmpty = errors.New("username and password cannot be empty")
ErrPasswordTooShort = errors.New("password must be at least 6 characters long")
)
type AccountLockedError struct {
LockedUntil time.Time
}
func (e AccountLockedError) Error() string {
return "account temporarily locked"
}

View File

@@ -1,6 +1,7 @@
package auth
import (
"errors"
"net/http"
"github.com/gin-gonic/gin"
@@ -35,6 +36,16 @@ func (h *Handler) Login(c *gin.Context) {
token, err := h.authService.Login(c.Request.Context(), req.Username, req.Password)
if err != nil {
var lockErr AccountLockedError
if errors.As(err, &lockErr) {
c.JSON(http.StatusTooManyRequests, gin.H{
"error": "Слишком много неверных попыток. Попробуйте через 5 минут",
"code": "account_temporarily_locked",
"locked_until": lockErr.LockedUntil.Format("2006-01-02T15:04:05Z07:00"),
})
return
}
switch err {
case ErrUserNotFound, ErrInvalidCredentials:
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})

View File

@@ -71,10 +71,33 @@ func (s *Service) Login(ctx context.Context, username, password string) (string,
return "", err
}
now := time.Now()
if user.LockedUntil.Valid {
if user.LockedUntil.Time.After(now) {
return "", AccountLockedError{LockedUntil: user.LockedUntil.Time}
}
if err := s.queries.ResetLoginFailures(ctx, username); err != nil {
return "", err
}
}
if !s.VerifyPassword(user.PasswordHash, password) {
failedLogin, err := s.queries.RecordFailedLogin(ctx, username)
if err != nil {
return "", err
}
if failedLogin.LockedUntil.Valid && failedLogin.LockedUntil.Time.After(now) {
return "", AccountLockedError{LockedUntil: failedLogin.LockedUntil.Time}
}
return "", ErrInvalidCredentials
}
if user.FailedLoginAttempts > 0 || user.LockedUntil.Valid {
if err := s.queries.ResetLoginFailures(ctx, username); err != nil {
return "", err
}
}
token, err := GenerateToken(uuid.UUID(user.ID.Bytes), s.secret, s.expiry)
if err != nil {
return "", err

View File

@@ -0,0 +1,3 @@
ALTER TABLE users
DROP COLUMN locked_until,
DROP COLUMN failed_login_attempts;

View File

@@ -0,0 +1,3 @@
ALTER TABLE users
ADD COLUMN failed_login_attempts INTEGER NOT NULL DEFAULT 0,
ADD COLUMN locked_until TIMESTAMPTZ;

View File

@@ -0,0 +1,2 @@
ALTER TABLE deliveries DROP COLUMN warehouse_request_source_2;
ALTER TABLE deliveries DROP COLUMN warehouse_request_source;

View File

@@ -0,0 +1,2 @@
ALTER TABLE deliveries ADD COLUMN warehouse_request_source varchar(20);
ALTER TABLE deliveries ADD COLUMN warehouse_request_source_2 varchar(20);

View File

@@ -6,16 +6,35 @@ RETURNING *;
-- name: GetUserByUsername :one
SELECT * FROM users WHERE username = $1;
-- name: ResetLoginFailures :exec
UPDATE users
SET failed_login_attempts = 0,
locked_until = NULL
WHERE username = $1;
-- name: RecordFailedLogin :one
UPDATE users
SET failed_login_attempts = CASE
WHEN failed_login_attempts + 1 >= 3 THEN 3
ELSE failed_login_attempts + 1
END,
locked_until = CASE
WHEN failed_login_attempts + 1 >= 3 THEN NOW() + INTERVAL '5 minutes'
ELSE locked_until
END
WHERE username = $1
RETURNING failed_login_attempts, locked_until;
-- name: GetDeliveriesByDate :many
SELECT * FROM deliveries WHERE date = $1;
-- name: CreateDelivery :one
INSERT INTO deliveries (
date, pickup_location, pickup_location_2, product_name, product_name_2,
date, pickup_location, pickup_location_2, warehouse_request_source, warehouse_request_source_2, product_name, product_name_2,
customer_name, address, street, house, apartment, entrance, floor,
phone, additional_phone, has_elevator, service_info, comment
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19)
RETURNING *;
-- name: GetDeliveryByID :one
@@ -29,22 +48,24 @@ UPDATE deliveries SET
date = $1,
pickup_location = $2,
pickup_location_2 = $3,
product_name = $4,
product_name_2 = $5,
customer_name = $6,
address = $7,
street = $8,
house = $9,
apartment = $10,
entrance = $11,
floor = $12,
phone = $13,
additional_phone = $14,
has_elevator = $15,
service_info = $16,
comment = $17,
warehouse_request_source = $4,
warehouse_request_source_2 = $5,
product_name = $6,
product_name_2 = $7,
customer_name = $8,
address = $9,
street = $10,
house = $11,
apartment = $12,
entrance = $13,
floor = $14,
phone = $15,
additional_phone = $16,
has_elevator = $17,
service_info = $18,
comment = $19,
updated_at = NOW()
WHERE id = $18;
WHERE id = $20;
-- name: GetDeliveryCount :many
SELECT COUNT(*) as count, date FROM deliveries

View File

@@ -9,32 +9,36 @@ import (
)
type Delivery struct {
ID pgtype.UUID `db:"id" json:"id"`
Date pgtype.Date `db:"date" json:"date"`
PickupLocation string `db:"pickup_location" json:"pickup_location"`
ProductName string `db:"product_name" json:"product_name"`
Address string `db:"address" json:"address"`
Phone string `db:"phone" json:"phone"`
AdditionalPhone pgtype.Text `db:"additional_phone" json:"additional_phone"`
HasElevator bool `db:"has_elevator" json:"has_elevator"`
Comment pgtype.Text `db:"comment" json:"comment"`
Status string `db:"status" json:"status"`
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
CustomerName string `db:"customer_name" json:"customer_name"`
ServiceInfo pgtype.Text `db:"service_info" json:"service_info"`
PickupLocation2 pgtype.Text `db:"pickup_location_2" json:"pickup_location_2"`
ProductName2 pgtype.Text `db:"product_name_2" json:"product_name_2"`
Street string `db:"street" json:"street"`
House string `db:"house" json:"house"`
Apartment pgtype.Text `db:"apartment" json:"apartment"`
Entrance pgtype.Text `db:"entrance" json:"entrance"`
Floor pgtype.Text `db:"floor" json:"floor"`
ID pgtype.UUID `db:"id" json:"id"`
Date pgtype.Date `db:"date" json:"date"`
PickupLocation string `db:"pickup_location" json:"pickup_location"`
ProductName string `db:"product_name" json:"product_name"`
Address string `db:"address" json:"address"`
Phone string `db:"phone" json:"phone"`
AdditionalPhone pgtype.Text `db:"additional_phone" json:"additional_phone"`
HasElevator bool `db:"has_elevator" json:"has_elevator"`
Comment pgtype.Text `db:"comment" json:"comment"`
Status string `db:"status" json:"status"`
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
CustomerName string `db:"customer_name" json:"customer_name"`
ServiceInfo pgtype.Text `db:"service_info" json:"service_info"`
PickupLocation2 pgtype.Text `db:"pickup_location_2" json:"pickup_location_2"`
ProductName2 pgtype.Text `db:"product_name_2" json:"product_name_2"`
Street string `db:"street" json:"street"`
House string `db:"house" json:"house"`
Apartment pgtype.Text `db:"apartment" json:"apartment"`
Entrance pgtype.Text `db:"entrance" json:"entrance"`
Floor pgtype.Text `db:"floor" json:"floor"`
WarehouseRequestSource pgtype.Text `db:"warehouse_request_source" json:"warehouse_request_source"`
WarehouseRequestSource2 pgtype.Text `db:"warehouse_request_source_2" json:"warehouse_request_source_2"`
}
type User struct {
ID pgtype.UUID `db:"id" json:"id"`
Username string `db:"username" json:"username"`
PasswordHash string `db:"password_hash" json:"password_hash"`
CreatedAt pgtype.Timestamp `db:"created_at" json:"created_at"`
ID pgtype.UUID `db:"id" json:"id"`
Username string `db:"username" json:"username"`
PasswordHash string `db:"password_hash" json:"password_hash"`
CreatedAt pgtype.Timestamp `db:"created_at" json:"created_at"`
FailedLoginAttempts int32 `db:"failed_login_attempts" json:"failed_login_attempts"`
LockedUntil pgtype.Timestamptz `db:"locked_until" json:"locked_until"`
}

View File

@@ -18,6 +18,8 @@ type Querier interface {
GetDeliveryByID(ctx context.Context, id pgtype.UUID) (Delivery, error)
GetDeliveryCount(ctx context.Context) ([]GetDeliveryCountRow, error)
GetUserByUsername(ctx context.Context, username string) (User, error)
RecordFailedLogin(ctx context.Context, username string) (RecordFailedLoginRow, error)
ResetLoginFailures(ctx context.Context, username string) error
UpdateDelivery(ctx context.Context, arg UpdateDeliveryParams) error
UpdateDeliveryStatus(ctx context.Context, arg UpdateDeliveryStatusParams) error
}

View File

@@ -13,32 +13,34 @@ import (
const createDelivery = `-- name: CreateDelivery :one
INSERT INTO deliveries (
date, pickup_location, pickup_location_2, product_name, product_name_2,
date, pickup_location, pickup_location_2, warehouse_request_source, warehouse_request_source_2, product_name, product_name_2,
customer_name, address, street, house, apartment, entrance, floor,
phone, additional_phone, has_elevator, service_info, comment
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)
RETURNING id, date, pickup_location, product_name, address, phone, additional_phone, has_elevator, comment, status, created_at, updated_at, customer_name, service_info, pickup_location_2, product_name_2, street, house, apartment, entrance, floor
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19)
RETURNING id, date, pickup_location, product_name, address, phone, additional_phone, has_elevator, comment, status, created_at, updated_at, customer_name, service_info, pickup_location_2, product_name_2, street, house, apartment, entrance, floor, warehouse_request_source, warehouse_request_source_2
`
type CreateDeliveryParams struct {
Date pgtype.Date `db:"date" json:"date"`
PickupLocation string `db:"pickup_location" json:"pickup_location"`
PickupLocation2 pgtype.Text `db:"pickup_location_2" json:"pickup_location_2"`
ProductName string `db:"product_name" json:"product_name"`
ProductName2 pgtype.Text `db:"product_name_2" json:"product_name_2"`
CustomerName string `db:"customer_name" json:"customer_name"`
Address string `db:"address" json:"address"`
Street string `db:"street" json:"street"`
House string `db:"house" json:"house"`
Apartment pgtype.Text `db:"apartment" json:"apartment"`
Entrance pgtype.Text `db:"entrance" json:"entrance"`
Floor pgtype.Text `db:"floor" json:"floor"`
Phone string `db:"phone" json:"phone"`
AdditionalPhone pgtype.Text `db:"additional_phone" json:"additional_phone"`
HasElevator bool `db:"has_elevator" json:"has_elevator"`
ServiceInfo pgtype.Text `db:"service_info" json:"service_info"`
Comment pgtype.Text `db:"comment" json:"comment"`
Date pgtype.Date `db:"date" json:"date"`
PickupLocation string `db:"pickup_location" json:"pickup_location"`
PickupLocation2 pgtype.Text `db:"pickup_location_2" json:"pickup_location_2"`
WarehouseRequestSource pgtype.Text `db:"warehouse_request_source" json:"warehouse_request_source"`
WarehouseRequestSource2 pgtype.Text `db:"warehouse_request_source_2" json:"warehouse_request_source_2"`
ProductName string `db:"product_name" json:"product_name"`
ProductName2 pgtype.Text `db:"product_name_2" json:"product_name_2"`
CustomerName string `db:"customer_name" json:"customer_name"`
Address string `db:"address" json:"address"`
Street string `db:"street" json:"street"`
House string `db:"house" json:"house"`
Apartment pgtype.Text `db:"apartment" json:"apartment"`
Entrance pgtype.Text `db:"entrance" json:"entrance"`
Floor pgtype.Text `db:"floor" json:"floor"`
Phone string `db:"phone" json:"phone"`
AdditionalPhone pgtype.Text `db:"additional_phone" json:"additional_phone"`
HasElevator bool `db:"has_elevator" json:"has_elevator"`
ServiceInfo pgtype.Text `db:"service_info" json:"service_info"`
Comment pgtype.Text `db:"comment" json:"comment"`
}
func (q *Queries) CreateDelivery(ctx context.Context, arg CreateDeliveryParams) (Delivery, error) {
@@ -46,6 +48,8 @@ func (q *Queries) CreateDelivery(ctx context.Context, arg CreateDeliveryParams)
arg.Date,
arg.PickupLocation,
arg.PickupLocation2,
arg.WarehouseRequestSource,
arg.WarehouseRequestSource2,
arg.ProductName,
arg.ProductName2,
arg.CustomerName,
@@ -84,6 +88,8 @@ func (q *Queries) CreateDelivery(ctx context.Context, arg CreateDeliveryParams)
&i.Apartment,
&i.Entrance,
&i.Floor,
&i.WarehouseRequestSource,
&i.WarehouseRequestSource2,
)
return i, err
}
@@ -91,7 +97,7 @@ func (q *Queries) CreateDelivery(ctx context.Context, arg CreateDeliveryParams)
const createUser = `-- name: CreateUser :one
INSERT INTO users (username, password_hash)
VALUES ($1, $2)
RETURNING id, username, password_hash, created_at
RETURNING id, username, password_hash, created_at, failed_login_attempts, locked_until
`
type CreateUserParams struct {
@@ -107,6 +113,8 @@ func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, e
&i.Username,
&i.PasswordHash,
&i.CreatedAt,
&i.FailedLoginAttempts,
&i.LockedUntil,
)
return i, err
}
@@ -121,7 +129,7 @@ func (q *Queries) DeleteDelivery(ctx context.Context, id pgtype.UUID) error {
}
const getDeliveriesByDate = `-- name: GetDeliveriesByDate :many
SELECT id, date, pickup_location, product_name, address, phone, additional_phone, has_elevator, comment, status, created_at, updated_at, customer_name, service_info, pickup_location_2, product_name_2, street, house, apartment, entrance, floor FROM deliveries WHERE date = $1
SELECT id, date, pickup_location, product_name, address, phone, additional_phone, has_elevator, comment, status, created_at, updated_at, customer_name, service_info, pickup_location_2, product_name_2, street, house, apartment, entrance, floor, warehouse_request_source, warehouse_request_source_2 FROM deliveries WHERE date = $1
`
func (q *Queries) GetDeliveriesByDate(ctx context.Context, date pgtype.Date) ([]Delivery, error) {
@@ -155,6 +163,8 @@ func (q *Queries) GetDeliveriesByDate(ctx context.Context, date pgtype.Date) ([]
&i.Apartment,
&i.Entrance,
&i.Floor,
&i.WarehouseRequestSource,
&i.WarehouseRequestSource2,
); err != nil {
return nil, err
}
@@ -167,7 +177,7 @@ func (q *Queries) GetDeliveriesByDate(ctx context.Context, date pgtype.Date) ([]
}
const getDeliveryByID = `-- name: GetDeliveryByID :one
SELECT id, date, pickup_location, product_name, address, phone, additional_phone, has_elevator, comment, status, created_at, updated_at, customer_name, service_info, pickup_location_2, product_name_2, street, house, apartment, entrance, floor FROM deliveries WHERE id = $1
SELECT id, date, pickup_location, product_name, address, phone, additional_phone, has_elevator, comment, status, created_at, updated_at, customer_name, service_info, pickup_location_2, product_name_2, street, house, apartment, entrance, floor, warehouse_request_source, warehouse_request_source_2 FROM deliveries WHERE id = $1
`
func (q *Queries) GetDeliveryByID(ctx context.Context, id pgtype.UUID) (Delivery, error) {
@@ -195,6 +205,8 @@ func (q *Queries) GetDeliveryByID(ctx context.Context, id pgtype.UUID) (Delivery
&i.Apartment,
&i.Entrance,
&i.Floor,
&i.WarehouseRequestSource,
&i.WarehouseRequestSource2,
)
return i, err
}
@@ -231,7 +243,7 @@ func (q *Queries) GetDeliveryCount(ctx context.Context) ([]GetDeliveryCountRow,
}
const getUserByUsername = `-- name: GetUserByUsername :one
SELECT id, username, password_hash, created_at FROM users WHERE username = $1
SELECT id, username, password_hash, created_at, failed_login_attempts, locked_until FROM users WHERE username = $1
`
func (q *Queries) GetUserByUsername(ctx context.Context, username string) (User, error) {
@@ -242,52 +254,96 @@ func (q *Queries) GetUserByUsername(ctx context.Context, username string) (User,
&i.Username,
&i.PasswordHash,
&i.CreatedAt,
&i.FailedLoginAttempts,
&i.LockedUntil,
)
return i, err
}
const recordFailedLogin = `-- name: RecordFailedLogin :one
UPDATE users
SET failed_login_attempts = CASE
WHEN failed_login_attempts + 1 >= 3 THEN 3
ELSE failed_login_attempts + 1
END,
locked_until = CASE
WHEN failed_login_attempts + 1 >= 3 THEN NOW() + INTERVAL '5 minutes'
ELSE locked_until
END
WHERE username = $1
RETURNING failed_login_attempts, locked_until
`
type RecordFailedLoginRow struct {
FailedLoginAttempts int32 `db:"failed_login_attempts" json:"failed_login_attempts"`
LockedUntil pgtype.Timestamptz `db:"locked_until" json:"locked_until"`
}
func (q *Queries) RecordFailedLogin(ctx context.Context, username string) (RecordFailedLoginRow, error) {
row := q.db.QueryRow(ctx, recordFailedLogin, username)
var i RecordFailedLoginRow
err := row.Scan(&i.FailedLoginAttempts, &i.LockedUntil)
return i, err
}
const resetLoginFailures = `-- name: ResetLoginFailures :exec
UPDATE users
SET failed_login_attempts = 0,
locked_until = NULL
WHERE username = $1
`
func (q *Queries) ResetLoginFailures(ctx context.Context, username string) error {
_, err := q.db.Exec(ctx, resetLoginFailures, username)
return err
}
const updateDelivery = `-- name: UpdateDelivery :exec
UPDATE deliveries SET
date = $1,
pickup_location = $2,
pickup_location_2 = $3,
product_name = $4,
product_name_2 = $5,
customer_name = $6,
address = $7,
street = $8,
house = $9,
apartment = $10,
entrance = $11,
floor = $12,
phone = $13,
additional_phone = $14,
has_elevator = $15,
service_info = $16,
comment = $17,
warehouse_request_source = $4,
warehouse_request_source_2 = $5,
product_name = $6,
product_name_2 = $7,
customer_name = $8,
address = $9,
street = $10,
house = $11,
apartment = $12,
entrance = $13,
floor = $14,
phone = $15,
additional_phone = $16,
has_elevator = $17,
service_info = $18,
comment = $19,
updated_at = NOW()
WHERE id = $18
WHERE id = $20
`
type UpdateDeliveryParams struct {
Date pgtype.Date `db:"date" json:"date"`
PickupLocation string `db:"pickup_location" json:"pickup_location"`
PickupLocation2 pgtype.Text `db:"pickup_location_2" json:"pickup_location_2"`
ProductName string `db:"product_name" json:"product_name"`
ProductName2 pgtype.Text `db:"product_name_2" json:"product_name_2"`
CustomerName string `db:"customer_name" json:"customer_name"`
Address string `db:"address" json:"address"`
Street string `db:"street" json:"street"`
House string `db:"house" json:"house"`
Apartment pgtype.Text `db:"apartment" json:"apartment"`
Entrance pgtype.Text `db:"entrance" json:"entrance"`
Floor pgtype.Text `db:"floor" json:"floor"`
Phone string `db:"phone" json:"phone"`
AdditionalPhone pgtype.Text `db:"additional_phone" json:"additional_phone"`
HasElevator bool `db:"has_elevator" json:"has_elevator"`
ServiceInfo pgtype.Text `db:"service_info" json:"service_info"`
Comment pgtype.Text `db:"comment" json:"comment"`
ID pgtype.UUID `db:"id" json:"id"`
Date pgtype.Date `db:"date" json:"date"`
PickupLocation string `db:"pickup_location" json:"pickup_location"`
PickupLocation2 pgtype.Text `db:"pickup_location_2" json:"pickup_location_2"`
WarehouseRequestSource pgtype.Text `db:"warehouse_request_source" json:"warehouse_request_source"`
WarehouseRequestSource2 pgtype.Text `db:"warehouse_request_source_2" json:"warehouse_request_source_2"`
ProductName string `db:"product_name" json:"product_name"`
ProductName2 pgtype.Text `db:"product_name_2" json:"product_name_2"`
CustomerName string `db:"customer_name" json:"customer_name"`
Address string `db:"address" json:"address"`
Street string `db:"street" json:"street"`
House string `db:"house" json:"house"`
Apartment pgtype.Text `db:"apartment" json:"apartment"`
Entrance pgtype.Text `db:"entrance" json:"entrance"`
Floor pgtype.Text `db:"floor" json:"floor"`
Phone string `db:"phone" json:"phone"`
AdditionalPhone pgtype.Text `db:"additional_phone" json:"additional_phone"`
HasElevator bool `db:"has_elevator" json:"has_elevator"`
ServiceInfo pgtype.Text `db:"service_info" json:"service_info"`
Comment pgtype.Text `db:"comment" json:"comment"`
ID pgtype.UUID `db:"id" json:"id"`
}
func (q *Queries) UpdateDelivery(ctx context.Context, arg UpdateDeliveryParams) error {
@@ -295,6 +351,8 @@ func (q *Queries) UpdateDelivery(ctx context.Context, arg UpdateDeliveryParams)
arg.Date,
arg.PickupLocation,
arg.PickupLocation2,
arg.WarehouseRequestSource,
arg.WarehouseRequestSource2,
arg.ProductName,
arg.ProductName2,
arg.CustomerName,

View File

@@ -1,10 +1,13 @@
package delivery
import (
"errors"
"log"
"net/http"
"time"
sqlc "github.com/chedius/delivery-tracker/internal/db/sqlc"
"github.com/chedius/delivery-tracker/internal/ws"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
@@ -12,31 +15,34 @@ import (
type Handler struct {
queries *sqlc.Queries
hub *ws.Hub
}
// DeliveryRequest represents the request body for creating or updating a delivery
type DeliveryRequest struct {
Date string `json:"date" binding:"required"` // DD-MM-YYYY
PickupLocation string `json:"pickup_location" binding:"required,oneof=warehouse symbat nursaya galaktika"`
PickupLocation2 *string `json:"pickup_location_2" binding:"omitempty,oneof=warehouse symbat nursaya galaktika"`
ProductName string `json:"product_name" binding:"required"`
ProductName2 *string `json:"product_name_2"`
CustomerName string `json:"customer_name" binding:"required"`
Address string `json:"address" binding:"required"`
Street string `json:"street" binding:"required"`
House string `json:"house" binding:"required"`
Apartment *string `json:"apartment"`
Entrance *string `json:"entrance"`
Floor *string `json:"floor"`
Phone string `json:"phone" binding:"required"`
AdditionalPhone *string `json:"additional_phone"`
HasElevator bool `json:"has_elevator"`
ServiceInfo *string `json:"service_info"`
Comment string `json:"comment"`
Date string `json:"date" binding:"required"` // DD-MM-YYYY
PickupLocation string `json:"pickup_location" binding:"required,oneof=warehouse symbat nursaya galaktika"`
PickupLocation2 *string `json:"pickup_location_2" binding:"omitempty,oneof=warehouse symbat nursaya galaktika"`
WarehouseRequestSource *string `json:"warehouse_request_source" binding:"omitempty,oneof=symbat nursaya galaktika"`
WarehouseRequestSource2 *string `json:"warehouse_request_source_2" binding:"omitempty,oneof=symbat nursaya galaktika"`
ProductName string `json:"product_name" binding:"required"`
ProductName2 *string `json:"product_name_2"`
CustomerName string `json:"customer_name" binding:"required"`
Address string `json:"address" binding:"required"`
Street string `json:"street" binding:"required"`
House string `json:"house" binding:"required"`
Apartment *string `json:"apartment"`
Entrance *string `json:"entrance"`
Floor *string `json:"floor"`
Phone string `json:"phone" binding:"required"`
AdditionalPhone *string `json:"additional_phone"`
HasElevator bool `json:"has_elevator"`
ServiceInfo *string `json:"service_info"`
Comment string `json:"comment"`
}
func NewHandler(queries *sqlc.Queries) *Handler {
return &Handler{queries: queries}
func NewHandler(queries *sqlc.Queries, hub *ws.Hub) *Handler {
return &Handler{queries: queries, hub: hub}
}
// GET /api/deliveries/:id
@@ -97,6 +103,10 @@ func (h *Handler) CreateDelivery(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body", "details": err.Error()})
return
}
if err := normalizeWarehouseRequestSources(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Parse date from DD-MM-YYYY
t, err := parseDate(req.Date)
@@ -106,23 +116,25 @@ func (h *Handler) CreateDelivery(c *gin.Context) {
}
params := sqlc.CreateDeliveryParams{
Date: pgtype.Date{Time: t, Valid: true},
PickupLocation: req.PickupLocation,
PickupLocation2: pgtype.Text{String: derefString(req.PickupLocation2), Valid: req.PickupLocation2 != nil},
ProductName: req.ProductName,
ProductName2: pgtype.Text{String: derefString(req.ProductName2), Valid: req.ProductName2 != nil},
CustomerName: req.CustomerName,
Address: req.Address,
Street: req.Street,
House: req.House,
Apartment: pgtype.Text{String: derefString(req.Apartment), Valid: req.Apartment != nil},
Entrance: pgtype.Text{String: derefString(req.Entrance), Valid: req.Entrance != nil},
Floor: pgtype.Text{String: derefString(req.Floor), Valid: req.Floor != nil},
Phone: req.Phone,
AdditionalPhone: pgtype.Text{String: derefString(req.AdditionalPhone), Valid: req.AdditionalPhone != nil},
HasElevator: req.HasElevator,
ServiceInfo: pgtype.Text{String: derefString(req.ServiceInfo), Valid: req.ServiceInfo != nil},
Comment: pgtype.Text{String: req.Comment, Valid: true},
Date: pgtype.Date{Time: t, Valid: true},
PickupLocation: req.PickupLocation,
PickupLocation2: pgtype.Text{String: derefString(req.PickupLocation2), Valid: req.PickupLocation2 != nil},
WarehouseRequestSource: pgtype.Text{String: derefString(req.WarehouseRequestSource), Valid: req.WarehouseRequestSource != nil},
WarehouseRequestSource2: pgtype.Text{String: derefString(req.WarehouseRequestSource2), Valid: req.WarehouseRequestSource2 != nil},
ProductName: req.ProductName,
ProductName2: pgtype.Text{String: derefString(req.ProductName2), Valid: req.ProductName2 != nil},
CustomerName: req.CustomerName,
Address: req.Address,
Street: req.Street,
House: req.House,
Apartment: pgtype.Text{String: derefString(req.Apartment), Valid: req.Apartment != nil},
Entrance: pgtype.Text{String: derefString(req.Entrance), Valid: req.Entrance != nil},
Floor: pgtype.Text{String: derefString(req.Floor), Valid: req.Floor != nil},
Phone: req.Phone,
AdditionalPhone: pgtype.Text{String: derefString(req.AdditionalPhone), Valid: req.AdditionalPhone != nil},
HasElevator: req.HasElevator,
ServiceInfo: pgtype.Text{String: derefString(req.ServiceInfo), Valid: req.ServiceInfo != nil},
Comment: pgtype.Text{String: req.Comment, Valid: true},
}
res, err := h.queries.CreateDelivery(c.Request.Context(), params)
if err != nil {
@@ -130,6 +142,7 @@ func (h *Handler) CreateDelivery(c *gin.Context) {
return
}
h.hub.Broadcast(ws.NewEvent(ws.DeliveryCreated, res))
c.JSON(http.StatusOK, gin.H{"message": "Delivery created", "id": res.ID.String()})
}
@@ -141,6 +154,10 @@ func (h *Handler) UpdateDelivery(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body", "details": err.Error()})
return
}
if err := normalizeWarehouseRequestSources(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
id := c.Param("id")
@@ -161,29 +178,36 @@ func (h *Handler) UpdateDelivery(c *gin.Context) {
}
if err := h.queries.UpdateDelivery(c.Request.Context(), sqlc.UpdateDeliveryParams{
ID: pgtype.UUID{Bytes: parsedID, Valid: true},
Date: pgtype.Date{Time: t, Valid: true},
PickupLocation: req.PickupLocation,
PickupLocation2: pgtype.Text{String: derefString(req.PickupLocation2), Valid: req.PickupLocation2 != nil},
ProductName: req.ProductName,
ProductName2: pgtype.Text{String: derefString(req.ProductName2), Valid: req.ProductName2 != nil},
CustomerName: req.CustomerName,
Address: req.Address,
Street: req.Street,
House: req.House,
Apartment: pgtype.Text{String: derefString(req.Apartment), Valid: req.Apartment != nil},
Entrance: pgtype.Text{String: derefString(req.Entrance), Valid: req.Entrance != nil},
Floor: pgtype.Text{String: derefString(req.Floor), Valid: req.Floor != nil},
Phone: req.Phone,
AdditionalPhone: pgtype.Text{String: derefString(req.AdditionalPhone), Valid: req.AdditionalPhone != nil},
HasElevator: req.HasElevator,
ServiceInfo: pgtype.Text{String: derefString(req.ServiceInfo), Valid: req.ServiceInfo != nil},
Comment: pgtype.Text{String: req.Comment, Valid: true},
ID: pgtype.UUID{Bytes: parsedID, Valid: true},
Date: pgtype.Date{Time: t, Valid: true},
PickupLocation: req.PickupLocation,
PickupLocation2: pgtype.Text{String: derefString(req.PickupLocation2), Valid: req.PickupLocation2 != nil},
WarehouseRequestSource: pgtype.Text{String: derefString(req.WarehouseRequestSource), Valid: req.WarehouseRequestSource != nil},
WarehouseRequestSource2: pgtype.Text{String: derefString(req.WarehouseRequestSource2), Valid: req.WarehouseRequestSource2 != nil},
ProductName: req.ProductName,
ProductName2: pgtype.Text{String: derefString(req.ProductName2), Valid: req.ProductName2 != nil},
CustomerName: req.CustomerName,
Address: req.Address,
Street: req.Street,
House: req.House,
Apartment: pgtype.Text{String: derefString(req.Apartment), Valid: req.Apartment != nil},
Entrance: pgtype.Text{String: derefString(req.Entrance), Valid: req.Entrance != nil},
Floor: pgtype.Text{String: derefString(req.Floor), Valid: req.Floor != nil},
Phone: req.Phone,
AdditionalPhone: pgtype.Text{String: derefString(req.AdditionalPhone), Valid: req.AdditionalPhone != nil},
HasElevator: req.HasElevator,
ServiceInfo: pgtype.Text{String: derefString(req.ServiceInfo), Valid: req.ServiceInfo != nil},
Comment: pgtype.Text{String: req.Comment, Valid: true},
}); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update delivery", "details": err.Error()})
return
}
if updated, err := h.queries.GetDeliveryByID(c.Request.Context(), pgtype.UUID{Bytes: parsedID, Valid: true}); err == nil {
h.hub.Broadcast(ws.NewEvent(ws.DeliveryUpdated, updated))
} else {
log.Printf("delivery: failed to fetch updated delivery %s for ws broadcast: %v", id, err)
}
c.JSON(http.StatusOK, gin.H{"message": "Delivery updated"})
}
@@ -225,6 +249,7 @@ func (h *Handler) UpdateDeliveryStatus(c *gin.Context) {
return
}
h.hub.Broadcast(ws.NewEvent(ws.DeliveryStatusChanged, ws.StatusPayload{ID: id, Status: status}))
c.JSON(http.StatusOK, gin.H{"message": "Delivery status updated"})
}
@@ -248,6 +273,7 @@ func (h *Handler) DeleteDelivery(c *gin.Context) {
return
}
h.hub.Broadcast(ws.NewEvent(ws.DeliveryDeleted, ws.DeletePayload{ID: id}))
c.JSON(http.StatusOK, gin.H{"message": "Delivery deleted"})
}
@@ -266,3 +292,19 @@ func derefString(s *string) string {
}
return *s
}
func normalizeWarehouseRequestSources(req *DeliveryRequest) error {
if req.PickupLocation == "warehouse" && req.WarehouseRequestSource == nil {
return errors.New("warehouse_request_source is required when pickup_location is warehouse")
}
if req.PickupLocation != "warehouse" {
req.WarehouseRequestSource = nil
}
if req.PickupLocation2 != nil && *req.PickupLocation2 == "warehouse" && req.WarehouseRequestSource2 == nil {
return errors.New("warehouse_request_source_2 is required when pickup_location_2 is warehouse")
}
if req.PickupLocation2 == nil || *req.PickupLocation2 != "warehouse" {
req.WarehouseRequestSource2 = nil
}
return nil
}

View File

@@ -0,0 +1,74 @@
package ws
import (
"time"
"github.com/gorilla/websocket"
)
const (
writeWait = 10 * time.Second
pongWait = 60 * time.Second
pingPeriod = (pongWait * 9) / 10
)
type Client struct {
hub *Hub
conn *websocket.Conn
send chan []byte
}
func NewClient(hub *Hub, conn *websocket.Conn) *Client {
return &Client{
hub: hub,
conn: conn,
send: make(chan []byte, 256),
}
}
// ReadPump keeps the connection alive and handles pong frames.
// We don't expect real messages from the client (read-only WS).
func (c *Client) ReadPump() {
defer func() {
c.hub.Unregister(c)
c.conn.Close()
}()
c.conn.SetReadLimit(512)
c.conn.SetReadDeadline(time.Now().Add(pongWait))
c.conn.SetPongHandler(func(string) error {
c.conn.SetReadDeadline(time.Now().Add(pongWait))
return nil
})
for {
if _, _, err := c.conn.ReadMessage(); err != nil {
break
}
}
}
// WritePump sends messages from hub to the client and pings to keep alive.
func (c *Client) WritePump() {
ticker := time.NewTicker(pingPeriod)
defer func() {
ticker.Stop()
c.conn.Close()
}()
for {
select {
case msg, ok := <-c.send:
c.conn.SetWriteDeadline(time.Now().Add(writeWait))
if !ok {
c.conn.WriteMessage(websocket.CloseMessage, nil)
return
}
if err := c.conn.WriteMessage(websocket.TextMessage, msg); err != nil {
return
}
case <-ticker.C:
c.conn.SetWriteDeadline(time.Now().Add(writeWait))
if err := c.conn.WriteMessage(websocket.PingMessage, nil); err != nil {
return
}
}
}
}

View File

@@ -0,0 +1,40 @@
package ws
import (
"encoding/json"
"log"
)
type EventType string
const (
DeliveryCreated EventType = "delivery.created"
DeliveryUpdated EventType = "delivery.updated"
DeliveryStatusChanged EventType = "delivery.status_changed"
DeliveryDeleted EventType = "delivery.deleted"
)
type Event struct {
Type EventType `json:"type"`
Payload any `json:"payload"`
}
type StatusPayload struct {
ID string `json:"id"`
Status string `json:"status"`
}
type DeletePayload struct {
ID string `json:"id"`
}
// NewEvent serializes an event to JSON. Returns nil on marshal failure;
// callers should treat nil as "skip broadcast".
func NewEvent(eventType EventType, payload any) []byte {
data, err := json.Marshal(Event{Type: eventType, Payload: payload})
if err != nil {
log.Printf("ws: failed to marshal event %s: %v", eventType, err)
return nil
}
return data
}

View File

@@ -0,0 +1,41 @@
package ws
import (
"net/http"
"github.com/chedius/delivery-tracker/internal/auth"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
)
var upgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool { return true },
}
// HandleWS returns a Gin handler that upgrades HTTP to WebSocket
// after validating the JWT token from the ?token= query param.
func HandleWS(hub *Hub, jwtSecret []byte) gin.HandlerFunc {
return func(c *gin.Context) {
token := c.Query("token")
if token == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing token"})
return
}
if _, err := auth.ParseToken(token, jwtSecret); err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
return
}
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
return
}
client := NewClient(hub, conn)
hub.Register(client)
go client.WritePump()
go client.ReadPump()
}
}

View File

@@ -0,0 +1,48 @@
package ws
import "sync"
type Hub struct {
mu sync.RWMutex
clients map[*Client]struct{}
}
func NewHub() *Hub {
return &Hub{
clients: make(map[*Client]struct{}),
}
}
func (h *Hub) Register(c *Client) {
h.mu.Lock()
h.clients[c] = struct{}{}
h.mu.Unlock()
}
func (h *Hub) Unregister(c *Client) {
h.mu.Lock()
if _, ok := h.clients[c]; ok {
delete(h.clients, c)
close(c.send)
}
h.mu.Unlock()
}
func (h *Hub) Broadcast(msg []byte) {
if msg == nil {
return
}
h.mu.RLock()
defer h.mu.RUnlock()
for c := range h.clients {
select {
case c.send <- msg:
default:
// Client too slow, schedule disconnect
go func(c *Client) {
h.Unregister(c)
c.conn.Close()
}(c)
}
}
}

View File

@@ -5,6 +5,9 @@ FROM node:20-alpine AS builder
WORKDIR /app
ARG GITEA_SHA
ARG COMMIT_SHA
# Copy package files
COPY package.json yarn.lock ./
RUN yarn install --frozen-lockfile

View File

@@ -10,12 +10,59 @@ server {
gzip_min_length 1024;
gzip_types text/plain text/css text/xml text/javascript application/javascript application/xml+rss application/json;
# Cache static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
# Hashed build assets — safe to cache forever
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# Other static files (icons, fonts at root etc.) — short cache
location ~* \.(png|jpg|jpeg|gif|ico|woff|woff2|ttf|eot)$ {
expires 7d;
add_header Cache-Control "public, max-age=604800";
}
# Never cache entry points — must always revalidate so clients
# can detect new frontend versions and service worker updates
location = /index.html {
add_header Cache-Control "no-cache, no-store, must-revalidate";
expires 0;
}
location = /sw.js {
add_header Cache-Control "no-cache, no-store, must-revalidate";
expires 0;
}
location = /registerSW.js {
add_header Cache-Control "no-cache, no-store, must-revalidate";
expires 0;
}
location = /manifest.webmanifest {
add_header Cache-Control "no-cache, no-store, must-revalidate";
expires 0;
}
location = /manifest.json {
add_header Cache-Control "no-cache, no-store, must-revalidate";
expires 0;
}
# WebSocket endpoint — long-lived connections need extended timeouts
location = /api/ws {
proxy_pass http://backend:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 86400s;
proxy_send_timeout 86400s;
}
# Proxy API requests to backend
location /api/ {
proxy_pass http://backend:8080;

View File

@@ -1,12 +1,12 @@
{
"name": "delivery-tracker",
"version": "0.0.0",
"version": "0.0.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "delivery-tracker",
"version": "0.0.0",
"version": "0.0.5",
"dependencies": {
"@tailwindcss/vite": "^4.2.2",
"date-fns": "^4.1.0",

View File

@@ -1,7 +1,7 @@
{
"name": "delivery-tracker",
"private": true,
"version": "0.0.0",
"version": "0.0.5",
"type": "module",
"scripts": {
"dev": "vite",

View File

@@ -4,8 +4,10 @@ import { DeliveryForm } from './components/delivery/DeliveryForm';
import { LoginForm } from './components/auth/LoginForm';
import { ToastContainer } from './components/ui/Toast';
import { Button } from './components/ui/Button';
import { UpdatePrompt } from './components/ui/UpdatePrompt';
import { useDeliveryStore } from './stores/deliveryStore';
import { useAuthStore } from './stores/authStore';
import { useWebSocket } from './hooks/useWebSocket';
// Lazy load pages for code splitting
const Dashboard = lazy(() => import('./pages/Dashboard'));
@@ -29,6 +31,8 @@ function App() {
const addDelivery = useDeliveryStore(state => state.addDelivery);
const fetchDeliveryCounts = useDeliveryStore(state => state.fetchDeliveryCounts);
useWebSocket();
// Restore auth on mount
useEffect(() => {
restoreAuth();
@@ -89,6 +93,7 @@ function App() {
if (!isAuthenticated) {
return (
<>
<UpdatePrompt />
<LoginForm />
<ToastContainer />
</>
@@ -97,6 +102,7 @@ function App() {
return (
<div className="min-h-screen bg-[#fbf8fb]">
<UpdatePrompt />
<header className="sticky top-0 z-40 bg-[#1B263B] text-white shadow-md">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between h-14">

View File

@@ -76,16 +76,16 @@ async function fetchApi<T>(
});
if (!response.ok) {
const errorData = await response.json().catch(() => null);
// Handle 401 unauthorized
if (response.status === 401) {
if (response.status === 401 && endpoint !== '/api/auth/login') {
handleUnauthorized();
throw new ApiError('Unauthorized', 401);
}
const errorData = await response.json().catch(() => null);
throw new ApiError(
errorData?.error || `HTTP ${response.status}`,
response.status,
errorData?.details
errorData
);
}

View File

@@ -1,13 +1,15 @@
import { api } from './client';
import { backendDateToFrontend } from '../utils/date';
import type { Delivery, PickupLocation, DeliveryStatus } from '../types';
import type { Delivery, DeliveryRequestSource, PickupLocation, DeliveryStatus } from '../types';
// Types matching backend responses
interface BackendDelivery {
export interface BackendDelivery {
id: string;
date: string; // YYYY-MM-DD from pgtype.Date
pickup_location: PickupLocation;
pickup_location_2: PickupLocation | null;
warehouse_request_source: DeliveryRequestSource | null;
warehouse_request_source_2: DeliveryRequestSource | null;
product_name: string;
product_name_2: string | null;
customer_name: string;
@@ -55,12 +57,14 @@ interface UpdateDeliveryResponse {
}
// Map backend delivery to frontend delivery
function mapBackendToFrontend(backend: BackendDelivery): Delivery {
export function mapBackendToFrontend(backend: BackendDelivery): Delivery {
return {
id: backend.id,
date: backendDateToFrontend(backend.date),
pickupLocation: backend.pickup_location,
pickupLocation2: backend.pickup_location_2 || undefined,
warehouseRequestSource: backend.warehouse_request_source || undefined,
warehouseRequestSource2: backend.warehouse_request_source_2 || undefined,
productName: backend.product_name,
productName2: backend.product_name_2 || undefined,
customerName: backend.customer_name,
@@ -115,6 +119,8 @@ export const deliveriesApi = {
date: data.date,
pickup_location: data.pickupLocation,
pickup_location_2: data.pickupLocation2 || null,
warehouse_request_source: data.pickupLocation === 'warehouse' ? data.warehouseRequestSource || null : null,
warehouse_request_source_2: data.pickupLocation2 === 'warehouse' ? data.warehouseRequestSource2 || null : null,
product_name: data.productName,
product_name_2: data.productName2 || null,
customer_name: data.customerName,
@@ -143,6 +149,8 @@ export const deliveriesApi = {
date: data.date,
pickup_location: data.pickupLocation,
pickup_location_2: data.pickupLocation2 || null,
warehouse_request_source: data.pickupLocation === 'warehouse' ? data.warehouseRequestSource || null : null,
warehouse_request_source_2: data.pickupLocation2 === 'warehouse' ? data.warehouseRequestSource2 || null : null,
product_name: data.productName,
product_name_2: data.productName2 || null,
customer_name: data.customerName,

View File

@@ -1,11 +1,18 @@
import { memo } from 'react';
import { MapPin, Phone, Store, Calendar, MessageSquare, CheckCircle2, Circle, CheckSquare, User, Wrench } from 'lucide-react';
import { MapPin, Phone, Store, Calendar, MessageSquare, CheckCircle2, Circle, CheckSquare, User, Wrench, CalendarPlus } from 'lucide-react';
import type { Delivery } from '../../types';
import { pickupLocationLabels } from '../../types';
import { formatPickupLocation } from '../../types';
import { StatusBadge } from './StatusBadge';
import { Card } from '../ui/Card';
const CITY = 'kokshetau';
const createdAtFormatter = new Intl.DateTimeFormat('ru-RU', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
});
const formatCreatedAt = (timestamp: number) => createdAtFormatter.format(new Date(timestamp));
interface DeliveryCardProps {
delivery: Delivery;
@@ -26,13 +33,17 @@ export const DeliveryCard = memo(({ delivery, onStatusChange, onEdit, onDelete }
return (
<Card className="relative">
<div className="flex items-start justify-between mb-3">
<div className="flex items-center gap-2">
<div className="flex items-start justify-between gap-3 mb-3">
<div className="flex min-w-0 flex-wrap items-center gap-3">
<StatusBadge
status={delivery.status}
onClick={() => onStatusChange(delivery.id)}
size="md"
/>
<div className="flex items-center gap-2 text-xs text-[#75777d]">
<CalendarPlus size={14} className="shrink-0" />
<span className="truncate">Создано {formatCreatedAt(delivery.createdAt)}</span>
</div>
</div>
<div className="flex gap-1">
<button
@@ -63,13 +74,13 @@ export const DeliveryCard = memo(({ delivery, onStatusChange, onEdit, onDelete }
<Store size={16} className="text-[#75777d] mt-0.5 shrink-0" />
<div className="flex flex-col gap-1 min-w-0">
<div className="flex items-baseline gap-2 flex-wrap">
<span className="text-[#1b1b1d] font-medium">{pickupLocationLabels[delivery.pickupLocation]}</span>
<span className="text-[#1b1b1d] font-medium">{formatPickupLocation(delivery.pickupLocation, delivery.warehouseRequestSource)}</span>
<span className="text-[#75777d]"></span>
<span className="text-[#1b1b1d]">{delivery.productName}</span>
</div>
{delivery.pickupLocation2 && (
<div className="flex items-baseline gap-2 flex-wrap">
<span className="text-[#1b1b1d] font-medium">{pickupLocationLabels[delivery.pickupLocation2]}</span>
<span className="text-[#1b1b1d] font-medium">{formatPickupLocation(delivery.pickupLocation2, delivery.warehouseRequestSource2)}</span>
<span className="text-[#75777d]"></span>
<span className="text-[#1b1b1d]">{delivery.productName2 || '—'}</span>
</div>

View File

@@ -1,8 +1,8 @@
import { useState, useEffect, useCallback } from 'react';
import { Button, Input, Select, Modal } from '../ui';
import { pickupOptions } from '../../constants/pickup';
import { deliveryRequestSourceOptions, pickupOptions } from '../../constants/pickup';
import { formatDateForInput, parseDateFromInput, getTodayFrontend } from '../../utils/date';
import type { Delivery, PickupLocation, DeliveryStatus } from '../../types';
import type { Delivery, DeliveryRequestSource, PickupLocation, DeliveryStatus } from '../../types';
interface DeliveryFormProps {
isOpen: boolean;
@@ -18,6 +18,10 @@ const PHONE_REGEX = /^\+7\s?\(?\d{3}\)?\s?\d{3}[\s-]?\d{2}[\s-]?\d{2}$/;
// City is not shown in UI but is included in the saved address (used for 2GIS search).
const CITY_LABEL = 'Кокшетау';
const requestSourceOptions = [
{ value: '', label: 'Выберите источник заявки' },
...deliveryRequestSourceOptions,
];
const buildAddressString = (
street: string,
@@ -38,6 +42,8 @@ export const DeliveryForm = ({ isOpen, onClose, onSubmit, initialData, defaultDa
date: defaultDate || getTodayFrontend(),
pickupLocation: 'warehouse' as PickupLocation,
pickupLocation2: null as PickupLocation | null,
warehouseRequestSource: null as DeliveryRequestSource | null,
warehouseRequestSource2: null as DeliveryRequestSource | null,
productName: '',
productName2: '',
customerName: '',
@@ -62,6 +68,8 @@ export const DeliveryForm = ({ isOpen, onClose, onSubmit, initialData, defaultDa
date: initialData.date,
pickupLocation: initialData.pickupLocation,
pickupLocation2: initialData.pickupLocation2 || null,
warehouseRequestSource: initialData.warehouseRequestSource || null,
warehouseRequestSource2: initialData.warehouseRequestSource2 || null,
productName: initialData.productName,
productName2: initialData.productName2 || '',
customerName: initialData.customerName,
@@ -91,7 +99,25 @@ export const DeliveryForm = ({ isOpen, onClose, onSubmit, initialData, defaultDa
const isPhoneValid = !formData.phone || validatePhone(formData.phone);
const isAdditionalPhoneValid = !formData.additionalPhone || validatePhone(formData.additionalPhone);
const isFormValid = formData.productName && formData.phone && isPhoneValid && formData.customerName && formData.street && formData.house;
const isWarehouseRequestSourceValid = formData.pickupLocation !== 'warehouse' || !!formData.warehouseRequestSource;
const isWarehouseRequestSource2Valid = !showSecondPickup || formData.pickupLocation2 !== 'warehouse' || !!formData.warehouseRequestSource2;
const isFormValid = formData.productName && formData.phone && isPhoneValid && formData.customerName && formData.street && formData.house && isWarehouseRequestSourceValid && isWarehouseRequestSource2Valid;
const handlePickupLocationChange = (pickupLocation: PickupLocation) => {
setFormData({
...formData,
pickupLocation,
warehouseRequestSource: pickupLocation === 'warehouse' ? formData.warehouseRequestSource : null,
});
};
const handlePickupLocation2Change = (pickupLocation2: PickupLocation) => {
setFormData({
...formData,
pickupLocation2,
warehouseRequestSource2: pickupLocation2 === 'warehouse' ? formData.warehouseRequestSource2 : null,
});
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
@@ -107,6 +133,8 @@ export const DeliveryForm = ({ isOpen, onClose, onSubmit, initialData, defaultDa
date: defaultDate || getTodayFrontend(),
pickupLocation: 'warehouse',
pickupLocation2: null,
warehouseRequestSource: null,
warehouseRequestSource2: null,
productName: '',
productName2: '',
customerName: '',
@@ -165,10 +193,21 @@ export const DeliveryForm = ({ isOpen, onClose, onSubmit, initialData, defaultDa
<Select
label="Место загрузки"
value={formData.pickupLocation}
onChange={(e) => setFormData({ ...formData, pickupLocation: e.target.value as PickupLocation })}
onChange={(e) => handlePickupLocationChange(e.target.value as PickupLocation)}
options={pickupOptions}
/>
{formData.pickupLocation === 'warehouse' && (
<Select
label="От кого заявка *"
value={formData.warehouseRequestSource || ''}
onChange={(e) => setFormData({ ...formData, warehouseRequestSource: e.target.value ? e.target.value as DeliveryRequestSource : null })}
options={requestSourceOptions}
required
error={!isWarehouseRequestSourceValid ? 'Выберите, от кого исходит заявка' : undefined}
/>
)}
<Input
label="Название товара"
value={formData.productName}
@@ -287,10 +326,14 @@ export const DeliveryForm = ({ isOpen, onClose, onSubmit, initialData, defaultDa
id="hasSecondPickup"
checked={showSecondPickup}
onChange={(e) => {
setShowSecondPickup(e.target.checked);
if (!e.target.checked) {
setFormData({ ...formData, pickupLocation2: null, productName2: '' });
}
const checked = e.target.checked;
setShowSecondPickup(checked);
setFormData({
...formData,
pickupLocation2: checked ? formData.pickupLocation2 || 'warehouse' : null,
warehouseRequestSource2: checked ? formData.warehouseRequestSource2 : null,
productName2: checked ? formData.productName2 : '',
});
}}
className="w-4 h-4 text-[#1B263B] border-[#c5c6cd] rounded focus:ring-[#1B263B]"
/>
@@ -305,9 +348,19 @@ export const DeliveryForm = ({ isOpen, onClose, onSubmit, initialData, defaultDa
<Select
label="Место загрузки 2"
value={formData.pickupLocation2 || ''}
onChange={(e) => setFormData({ ...formData, pickupLocation2: e.target.value as PickupLocation })}
onChange={(e) => handlePickupLocation2Change(e.target.value as PickupLocation)}
options={pickupOptions}
/>
{formData.pickupLocation2 === 'warehouse' && (
<Select
label="От кого заявка 2 *"
value={formData.warehouseRequestSource2 || ''}
onChange={(e) => setFormData({ ...formData, warehouseRequestSource2: e.target.value ? e.target.value as DeliveryRequestSource : null })}
options={requestSourceOptions}
required
error={!isWarehouseRequestSource2Valid ? 'Выберите, от кого исходит заявка' : undefined}
/>
)}
<Input
label="Название товара 2"
value={formData.productName2}

View File

@@ -1,7 +1,7 @@
import { memo } from 'react';
import { MapPin, Phone } from 'lucide-react';
import type { Delivery } from '../../types';
import { pickupLocationLabels } from '../../types';
import { formatPickupLocation } from '../../types';
import { StatusBadge } from './StatusBadge';
const CITY = 'kokshetau';
@@ -37,8 +37,8 @@ export const DeliveryRow = memo(({ delivery, onStatusChange, onEdit, onDelete }:
<td className="px-4 py-3 text-sm text-[#1b1b1d]">{delivery.date}</td>
<td className="px-4 py-3 text-sm text-[#1b1b1d]">
{delivery.pickupLocation2
? `${pickupLocationLabels[delivery.pickupLocation]} + ${pickupLocationLabels[delivery.pickupLocation2]}`
: pickupLocationLabels[delivery.pickupLocation]}
? `${formatPickupLocation(delivery.pickupLocation, delivery.warehouseRequestSource)} + ${formatPickupLocation(delivery.pickupLocation2, delivery.warehouseRequestSource2)}`
: formatPickupLocation(delivery.pickupLocation, delivery.warehouseRequestSource)}
</td>
<td className="px-4 py-3 text-sm text-[#1b1b1d]">
{delivery.productName}

View File

@@ -0,0 +1,55 @@
import { useEffect } from 'react';
import { useRegisterSW } from 'virtual:pwa-register/react';
// Check for SW updates every hour and on tab focus/visibility change
const UPDATE_INTERVAL_MS = 60 * 60 * 1000;
const buildLabel = `${__APP_VERSION__}-${__APP_COMMIT__}`;
export function UpdatePrompt() {
const {
needRefresh: [needRefresh],
updateServiceWorker,
} = useRegisterSW({
onRegisteredSW(_swUrl, registration) {
console.info('[App] version', {
version: __APP_VERSION__,
commit: __APP_COMMIT__,
builtAt: __APP_BUILT_AT__,
serviceWorker: registration ? 'registered' : 'unavailable',
});
if (!registration) return;
const checkForUpdate = async () => {
if (registration.installing || !navigator) return;
if ('connection' in navigator && !navigator.onLine) return;
try {
console.debug('[PWA] checking for update', { version: buildLabel });
await registration.update();
} catch {
console.debug('[PWA] update check failed, will retry later', { version: buildLabel });
}
};
setInterval(checkForUpdate, UPDATE_INTERVAL_MS);
const onVisible = () => {
if (document.visibilityState === 'visible') checkForUpdate();
};
document.addEventListener('visibilitychange', onVisible);
window.addEventListener('focus', checkForUpdate);
},
});
useEffect(() => {
if (needRefresh) {
console.info('[PWA] new version detected, applying automatically', {
currentVersion: buildLabel,
});
updateServiceWorker(true);
}
}, [needRefresh, updateServiceWorker]);
return null;
}

View File

@@ -1,5 +1,5 @@
import type { PickupLocation } from '../types';
import { pickupLocationLabels } from '../types';
import type { DeliveryRequestSource, PickupLocation } from '../types';
import { deliveryRequestSourceLabels, pickupLocationLabels } from '../types';
export const pickupOptions: { value: PickupLocation; label: string }[] = [
{ value: 'warehouse', label: pickupLocationLabels.warehouse },
@@ -12,3 +12,9 @@ export const pickupFilterOptions: { value: PickupLocation | 'all'; label: string
{ value: 'all', label: 'Все места загрузки' },
...pickupOptions,
];
export const deliveryRequestSourceOptions: { value: DeliveryRequestSource; label: string }[] = [
{ value: 'symbat', label: deliveryRequestSourceLabels.symbat },
{ value: 'nursaya', label: deliveryRequestSourceLabels.nursaya },
{ value: 'galaktika', label: deliveryRequestSourceLabels.galaktika },
];

View File

@@ -1,83 +1,133 @@
import { useEffect, useCallback } from 'react';
import { useEffect } from 'react';
import { useAuthStore } from '../stores/authStore';
import { useDeliveryStore } from '../stores/deliveryStore';
import type { Delivery } from '../types';
import { mapBackendToFrontend } from '../api/deliveries';
import type { BackendDelivery } from '../api/deliveries';
type WebSocketEvent =
| { type: 'delivery.created'; payload: Delivery }
| { type: 'delivery.updated'; payload: Delivery }
| { type: 'delivery.deleted'; payload: { id: string } };
type WsEventType =
| 'delivery.created'
| 'delivery.updated'
| 'delivery.status_changed'
| 'delivery.deleted';
type EventHandler = (event: WebSocketEvent) => void;
interface WsEvent {
type: WsEventType;
payload: unknown;
}
class MockWebSocket {
private handlers: EventHandler[] = [];
private isConnected = false;
const MAX_RECONNECT_DELAY = 30_000;
const INITIAL_RECONNECT_DELAY = 1_000;
const COUNTS_DEBOUNCE_MS = 300;
connect() {
this.isConnected = true;
console.log('WebSocket connected (mock)');
function getWsUrl(token: string): string {
const apiBase = import.meta.env.VITE_API_URL || '';
let wsBase: string;
if (apiBase) {
wsBase = apiBase.replace(/^http/, 'ws');
} else {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
wsBase = `${protocol}//${window.location.host}`;
}
disconnect() {
this.isConnected = false;
console.log('WebSocket disconnected (mock)');
}
return `${wsBase}/api/ws?token=${encodeURIComponent(token)}`;
}
subscribe(handler: EventHandler) {
this.handlers.push(handler);
return () => {
this.handlers = this.handlers.filter((h) => h !== handler);
};
}
// Debounced counts refresh — multiple WS events in quick succession
// trigger only a single API call.
let countsTimer: ReturnType<typeof setTimeout> | null = null;
function refreshCountsDebounced() {
if (countsTimer) clearTimeout(countsTimer);
countsTimer = setTimeout(() => {
countsTimer = null;
useDeliveryStore.getState().fetchDeliveryCounts();
}, COUNTS_DEBOUNCE_MS);
}
emit(event: WebSocketEvent) {
if (!this.isConnected) return;
this.handlers.forEach((handler) => handler(event));
}
function handleEvent(event: WsEvent) {
const store = useDeliveryStore.getState();
simulateIncomingEvent(event: WebSocketEvent) {
this.emit(event);
switch (event.type) {
case 'delivery.created': {
const delivery = mapBackendToFrontend(event.payload as BackendDelivery);
store.handleWsDeliveryCreated(delivery);
refreshCountsDebounced();
break;
}
case 'delivery.updated': {
const delivery = mapBackendToFrontend(event.payload as BackendDelivery);
store.handleWsDeliveryUpdated(delivery);
refreshCountsDebounced();
break;
}
case 'delivery.status_changed': {
const { id, status } = event.payload as { id: string; status: string };
store.handleWsStatusChanged(id, status);
break;
}
case 'delivery.deleted': {
const { id } = event.payload as { id: string };
store.handleWsDeliveryDeleted(id);
refreshCountsDebounced();
break;
}
}
}
const mockWebSocket = new MockWebSocket();
export const useWebSocket = () => {
const { addDelivery, updateDelivery, deleteDelivery } = useDeliveryStore();
export function useWebSocket() {
const token = useAuthStore(state => state.token);
useEffect(() => {
mockWebSocket.connect();
if (!token) return;
const unsubscribe = mockWebSocket.subscribe((event) => {
switch (event.type) {
case 'delivery.created':
addDelivery(event.payload);
break;
case 'delivery.updated':
updateDelivery(event.payload.id, event.payload);
break;
case 'delivery.deleted':
deleteDelivery(event.payload.id);
break;
}
});
let cancelled = false;
let ws: WebSocket | null = null;
let reconnectTimer: ReturnType<typeof setTimeout> | undefined;
let reconnectDelay = INITIAL_RECONNECT_DELAY;
function connect() {
if (cancelled) return;
const socket = new WebSocket(getWsUrl(token!));
ws = socket;
socket.onopen = () => {
reconnectDelay = INITIAL_RECONNECT_DELAY;
};
socket.onmessage = (e) => {
try {
const event: WsEvent = JSON.parse(e.data);
handleEvent(event);
} catch {
// ignore malformed messages
}
};
socket.onclose = () => {
// Ignore close events from sockets we no longer track —
// protects against stale callbacks after token change.
if (cancelled || ws !== socket) return;
ws = null;
reconnectTimer = setTimeout(() => {
reconnectDelay = Math.min(reconnectDelay * 2, MAX_RECONNECT_DELAY);
connect();
}, reconnectDelay);
};
socket.onerror = () => {
socket.close();
};
}
connect();
return () => {
unsubscribe();
mockWebSocket.disconnect();
cancelled = true;
clearTimeout(reconnectTimer);
ws?.close();
ws = null;
};
}, [addDelivery, updateDelivery, deleteDelivery]);
const sendEvent = useCallback((event: WebSocketEvent) => {
mockWebSocket.emit(event);
}, []);
return { sendEvent, isConnected: true };
};
export const simulateIncomingDelivery = (delivery: Delivery) => {
mockWebSocket.simulateIncomingEvent({
type: 'delivery.created',
payload: delivery,
});
};
}, [token]);
}

View File

@@ -3,18 +3,6 @@ import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js', { scope: '/' })
.then((registration) => {
console.log('SW registered:', registration)
})
.catch((error) => {
console.log('SW registration failed:', error)
})
})
}
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />

View File

@@ -4,7 +4,7 @@ import { format, startOfMonth, endOfMonth, eachDayOfInterval, isToday, getDay }
import { ru } from 'date-fns/locale';
import { useDeliveryStore } from '../stores/deliveryStore';
import type { Delivery } from '../types';
import { pickupLocationLabels } from '../types';
import { formatPickupLocation } from '../types';
import { Button } from '../components/ui/Button';
import { Card } from '../components/ui/Card';
@@ -78,7 +78,7 @@ const Dashboard = ({ onDateSelect, onAddDelivery }: DashboardProps) => {
</tr>
${dayDeliveries.map((d: Delivery) => `
<tr>
<td>${d.pickupLocation2 ? pickupLocationLabels[d.pickupLocation] + ' + ' + pickupLocationLabels[d.pickupLocation2] : pickupLocationLabels[d.pickupLocation]}</td>
<td>${d.pickupLocation2 ? formatPickupLocation(d.pickupLocation, d.warehouseRequestSource) + ' + ' + formatPickupLocation(d.pickupLocation2, d.warehouseRequestSource2) : formatPickupLocation(d.pickupLocation, d.warehouseRequestSource)}</td>
<td>${d.productName}${d.productName2 ? '<br><small>+ ' + d.productName2 + '</small>' : ''}</td>
<td>${d.customerName}</td>
<td>

View File

@@ -1,5 +1,6 @@
import { create } from 'zustand';
import { authApi } from '../api/auth';
import { ApiError } from '../api/client';
import { useToastStore } from './toastStore';
import type { User, LoginRequest } from '../types';
@@ -16,6 +17,28 @@ interface AuthState {
const TOKEN_KEY = 'auth_token';
const USER_KEY = 'auth_user';
const LOCKED_ACCOUNT_CODE = 'account_temporarily_locked';
function getLoginErrorMessage(error: unknown): string {
if (error instanceof ApiError) {
const details = error.details as { code?: string; locked_until?: string } | null;
if (error.status === 429 && details?.code === LOCKED_ACCOUNT_CODE) {
if (details.locked_until) {
const lockedUntil = new Date(details.locked_until);
const secondsLeft = Math.ceil((lockedUntil.getTime() - Date.now()) / 1000);
if (Number.isFinite(secondsLeft) && secondsLeft > 0) {
const minutesLeft = Math.ceil(secondsLeft / 60);
return `Слишком много неверных попыток. Попробуйте через ${minutesLeft} мин.`;
}
}
return 'Слишком много неверных попыток. Попробуйте через 5 минут';
}
}
return error instanceof Error ? error.message : 'Ошибка входа';
}
export const useAuthStore = create<AuthState>((set) => ({
token: null,
@@ -45,8 +68,7 @@ export const useAuthStore = create<AuthState>((set) => ({
useToastStore.getState().addToast('Вход выполнен успешно', 'success');
} catch (error) {
set({ isLoading: false });
const message = error instanceof Error ? error.message : 'Ошибка входа';
useToastStore.getState().addToast(message, 'error');
useToastStore.getState().addToast(getLoginErrorMessage(error), 'error');
throw error;
}
},

View File

@@ -7,6 +7,7 @@ interface DeliveryState {
// Data
deliveries: Delivery[];
deliveryCounts: Record<string, number>;
currentDate: string | null;
// Loading states
isLoading: boolean;
@@ -24,19 +25,26 @@ interface DeliveryState {
getDeliveriesByDateRange: (startDate: string, endDate: string) => Delivery[];
getDeliveryCountsByDate: () => Record<string, number>;
clearError: () => void;
// WebSocket event handlers
handleWsDeliveryCreated: (delivery: Delivery) => void;
handleWsDeliveryUpdated: (delivery: Delivery) => void;
handleWsStatusChanged: (id: string, status: string) => void;
handleWsDeliveryDeleted: (id: string) => void;
}
export const useDeliveryStore = create<DeliveryState>()((set, get) => ({
// Initial state
deliveries: [],
deliveryCounts: {},
currentDate: null,
isLoading: false,
isLoadingCounts: false,
error: null,
// Fetch deliveries for a specific date
fetchDeliveriesByDate: async (date: string) => {
set({ isLoading: true, error: null });
set({ isLoading: true, error: null, currentDate: date });
try {
const deliveries = await deliveriesApi.getByDate(date);
set({ deliveries, isLoading: false });
@@ -174,4 +182,42 @@ export const useDeliveryStore = create<DeliveryState>()((set, get) => ({
},
clearError: () => set({ error: null }),
// WebSocket event handlers (update local state without refetching)
handleWsDeliveryCreated: (delivery: Delivery) => {
const { currentDate, deliveries } = get();
if (currentDate && delivery.date === currentDate) {
if (!deliveries.some(d => d.id === delivery.id)) {
set({ deliveries: [...deliveries, delivery] });
}
}
},
handleWsDeliveryUpdated: (delivery: Delivery) => {
const { currentDate, deliveries } = get();
const exists = deliveries.some(d => d.id === delivery.id);
if (exists) {
if (delivery.date === currentDate) {
set({ deliveries: deliveries.map(d => d.id === delivery.id ? delivery : d) });
} else {
set({ deliveries: deliveries.filter(d => d.id !== delivery.id) });
}
} else if (currentDate && delivery.date === currentDate) {
set({ deliveries: [...deliveries, delivery] });
}
},
handleWsStatusChanged: (id: string, status: string) => {
set(state => ({
deliveries: state.deliveries.map(d =>
d.id === id ? { ...d, status: status as DeliveryStatus, updatedAt: Date.now() } : d
),
}));
},
handleWsDeliveryDeleted: (id: string) => {
set(state => ({
deliveries: state.deliveries.filter(d => d.id !== id),
}));
},
}));

View File

@@ -1,4 +1,5 @@
export type PickupLocation = 'warehouse' | 'symbat' | 'nursaya' | 'galaktika';
export type DeliveryRequestSource = 'symbat' | 'nursaya' | 'galaktika';
export type DeliveryStatus = 'new' | 'delivered';
export interface Delivery {
@@ -6,6 +7,8 @@ export interface Delivery {
date: string; // DD-MM-YYYY
pickupLocation: PickupLocation;
pickupLocation2?: PickupLocation | null;
warehouseRequestSource?: DeliveryRequestSource | null;
warehouseRequestSource2?: DeliveryRequestSource | null;
productName: string;
productName2?: string | null;
customerName: string;
@@ -32,6 +35,21 @@ export const pickupLocationLabels: Record<PickupLocation, string> = {
galaktika: 'Галактика',
};
export const deliveryRequestSourceLabels: Record<DeliveryRequestSource, string> = {
symbat: 'Сымбат',
nursaya: 'Нурсая',
galaktika: 'Галактика',
};
export const formatPickupLocation = (
pickupLocation: PickupLocation,
warehouseRequestSource?: DeliveryRequestSource | null,
): string => {
if (pickupLocation !== 'warehouse' || !warehouseRequestSource) {
return pickupLocationLabels[pickupLocation];
}
return `${pickupLocationLabels[pickupLocation]} · от ${deliveryRequestSourceLabels[warehouseRequestSource]}`;
};
export const statusLabels: Record<DeliveryStatus, string> = {
new: 'Новое',

5
frontend/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,5 @@
/// <reference types="vite/client" />
declare const __APP_VERSION__: string;
declare const __APP_COMMIT__: string;
declare const __APP_BUILT_AT__: string;

View File

@@ -5,7 +5,7 @@
"useDefineForClassFields": true,
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"types": ["vite/client", "vite-plugin-pwa/client", "vite-plugin-pwa/react"],
"skipLibCheck": true,
/* Bundler mode */

View File

@@ -3,16 +3,31 @@ import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
import { VitePWA } from 'vite-plugin-pwa'
const appVersion = process.env.npm_package_version ?? '0.0.0'
const appCommit = (
process.env.GITEA_SHA ??
process.env.GITHUB_SHA ??
process.env.COMMIT_SHA ??
'dev'
).slice(0, 12)
const appBuiltAt = new Date().toISOString()
// https://vite.dev/config/
export default defineConfig({
define: {
__APP_VERSION__: JSON.stringify(appVersion),
__APP_COMMIT__: JSON.stringify(appCommit),
__APP_BUILT_AT__: JSON.stringify(appBuiltAt),
},
plugins: [
react(),
tailwindcss(),
VitePWA({
registerType: 'autoUpdate',
registerType: 'prompt',
manifest: false, // manifest.json from public
workbox: {
globPatterns: ['**/*.{js,css,html,ico,png,svg,json}'],
cleanupOutdatedCaches: true,
runtimeCaching: [
{
urlPattern: /^https:\/\/.*\/api\//,
@@ -31,7 +46,8 @@ export default defineConfig({
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
ws: true,
},
},
},
})
})