updates
This commit is contained in:
28
server/.env.example
Normal file
28
server/.env.example
Normal file
@@ -0,0 +1,28 @@
|
||||
# Server
|
||||
SERVER_PORT=8555
|
||||
SERVER_ENV=development
|
||||
|
||||
# Database
|
||||
DB_HOST=localhost
|
||||
DB_PORT=8553
|
||||
DB_USER=postgres
|
||||
DB_PASSWORD=postgres
|
||||
DB_NAME=biostacker
|
||||
DB_SSL_MODE=disable
|
||||
|
||||
# Redis
|
||||
REDIS_ADDR=localhost:8554
|
||||
REDIS_PASSWORD=
|
||||
REDIS_DB=0
|
||||
|
||||
# SMTP
|
||||
SMTP_HOST=smtp.gmail.com
|
||||
SMTP_PORT=587
|
||||
SMTP_USERNAME=your-email@gmail.com
|
||||
SMTP_PASSWORD=your-app-specific-password
|
||||
SMTP_FROM=noreply@yourapp.com
|
||||
|
||||
# JWT
|
||||
JWT_SECRET=LS0tLS1CRUdJTiBQUklWQVRFIEtFWS0tLS0tCk1JSUV2UUlCQURBTkJna3Foa2lHOXcwQkFRRUZBQVNDQktjd2dnU2pBZ0VBQW9JQkFRQ2hLSFdmNmFTUjFjeW8KdHVCNzBEV1J6WEx4V2lZaHgrWXB6QzV2dGxDWkFteU1HM3RxTzd3RjlFczdqVWtjY2dJQ0VzamZHZDQrOEVsaAppSU1DNnN2ZmNpcUZ4YldqbmliY0dOdFV0dmpzU3NxdmhHTDZKVGxyS3k0NlF4anlHUTRZdm40RVFqb0hkbE5rCnRyMXdVWXdFU0VXYUppQ0MwdTd3SWpoWUh5alJaNUVSWExsa29SUnIxNUt4Z2tTZ1kwUjlURS9LaWVraXZIdXgKT092QWh6UWF6KzdsckpIcWZoL1g5OWtLbjBoeTh4dXFJWXoxUVFnMUQ3S0twcXRnMUFRbDBRSDlBL1hQenhaZgoyWXZodkRrNnVmOHh5RUJZUlFPa2xiTGVEdUVZNXRTVTVaOFFvU2pMQmpwdTBMdC9hMmYxWlBmWW91Y2svcFZiCk9DVGliOWdyQWdNQkFBRUNnZ0VBUjhYd3FPRVNHWjhaOEZQT0kyWkZ6V011RzE1V1dEb2lnQi8rMkdMZWYxNnMKZ0RPbklkZHJ0RTBxQ21Jd212b05lZVhxenkzQ3BNNDRLRGQzZlorYlg2OEZVQ0dPOVVrMHJsWmxyRk4zQmltRApIbXM3OTRNSGtQcWdzbkw2azZ2ajh0STM1bWFtV0hkeXlZcDNVU1FJVTBucXNhM2tVYzltZWMwTHdmZFNvdnVqClhndkUxRzhPSGN4cEpLNTZTOHM2RklLTHc4aW1rT1ZSZFVLWGoxdFNyd0VKTHU2emR4UVdWMnQvRXZRakkvVVEKNjZ5MHhiTzRGb1ZvekhYd3NrWlNBbjlkVzcxRHVKVjRrQjZuOTcvaUZFZ3p6dVRjVU1UMS8xTndDS0hlQVo5TgpGbUhnZkxIbTRuRGJQUUpEWHJjY3JwNkJ2OVIrakNVOUlRMG5TTndsbVFLQmdRRFY3bjAzbFZNd1hPUjlZUEhHCjVndkU2d3JWWmVkL2pRMU9IQnVDbzlEc2Z5ZjI2UTNMMlVITm1QNm44ZnJ0UmVQa3U4YkUySzBHMm03SGsvVG4KbnVndXFUUGtocnlVSGJDeUtkUDRreU5EbXl2bWhhOFVaSXJXNW90cEFTbm8vZHF3SlBOQmpZZ2RnQk1zMGhZago1RHJOYm5hSlFVN3ZKNXRhRkZtSlR5QUc2UUtCZ1FEQTJVMC8xNXg5bWtpUnNyYS8rTklwQUN2YkFGM1NOQTFpCkNUa3NDRDhIejJnaDhtdXlpRG1zVk9Tc0lEUjkxbk1ZRVpHT2FLbEVpbzE4enBRazRDUWh0MUJkNVhoMnUrc2kKRmluOXlDd0VsSjZrVUFRS2NqZnFoMG5lcnAvZ0YxRXVNcHFCRUhFR0t1WUZ3QlJ6YjIrOTN5Yk8xdndpUW9ORwpCd0s5cEROaDh3S0JnUURReVg0cHRqSEhYSkdmRC9OSGRCTCtiNHBXTkt0WG4vamhSNnROdDhWYVdzdE5QYXk2ClMySGVYemdCL3JjdnhPc2l2R1RFanRkbmZkMXFLS3QzTm01Unc4OGlkS0V0U1VDKzBQWFFmd0dHcExXV3VOZmoKWmpEZWhZaC94YVA2Z1c1aVJOMm9GNUpGZ0U2MmlwOFREbGFaVWZxY0FFSWlSQnhwTUwwbHRqU0NxUUtCZ0FLUQpJTVd6Y09IK2RlNXh5Sm4ralpSNzZ4bExCUFF4T3VoTnBSUGZ2QzYzWS9QbmkrVGdpSnV3dVNWTWZFWWIzb1c0ClhnM2RlRHB2K1BkcXEyOWVCenpuZWNyMXJNY3ZNaTNPeTVvUzJmcnBtcjRtVGhkeGN5ckx4NENOSTVUUDJvVloKcU5JRVRPdy9EN1dOMnZlNXlHdG1sdFp5NXdEeGoxc1Q1c1pzY3o1ZkFvR0FVQ1JLbWRBaVlHMU9UUzk2aHRKWgp1aXl5amtaeGN0Z3RHWGNkNVF4Nm1TY3U0ZC83Snl2SFQyU0x6U0RYTjI1ZWpjSDc0S1B2bU5yYmNNdGNzMi9pCmVRM3pyQWR0T3FmTzZ6OWJHQm5Ya2gxZmFPazZjK092SFZVNUVhWjdia3VkUEdFU3VtRm9iREh6RzUvUmZpVzYKeFFQak1mMGQ1TUdkNWhqaEFDT0tPTHc9Ci0tLS0tRU5EIFBSSVZBVEUgS0VZLS0tLS0K
|
||||
JWT_EXPIRY=480h
|
||||
JWT_PUBLIC_KEY=LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0KTUlJQklqQU5CZ2txaGtpRzl3MEJBUUVGQUFPQ0FROEFNSUlCQ2dLQ0FRRUFvU2gxbitta2tkWE1xTGJnZTlBMQprYzF5OFZvbUljZm1LY3d1YjdaUW1RSnNqQnQ3YWp1OEJmUkxPNDFKSEhJQ0FoTEkzeG5lUHZCSllZaURBdXJMCjMzSXFoY1cxbzU0bTNCamJWTGI0N0VyS3I0UmkraVU1YXlzdU9rTVk4aGtPR0w1K0JFSTZCM1pUWkxhOWNGR00KQkVoRm1pWWdndEx1OENJNFdCOG8wV2VSRVZ5NVpLRVVhOWVTc1lKRW9HTkVmVXhQeW9ucElyeDdzVGpyd0ljMApHcy91NWF5UjZuNGYxL2ZaQ3A5SWN2TWJxaUdNOVVFSU5RK3lpcWFyWU5RRUpkRUIvUVAxejg4V1g5bUw0Ync1Ck9ybi9NY2hBV0VVRHBKV3kzZzdoR09iVWxPV2ZFS0VveXdZNmJ0QzdmMnRuOVdUMzJLTG5KUDZWV3pnazRtL1kKS3dJREFRQUIKLS0tLS1FTkQgUFVCTElDIEtFWS0tLS0tCg==
|
||||
28
server/.gitignore
vendored
Normal file
28
server/.gitignore
vendored
Normal file
@@ -0,0 +1,28 @@
|
||||
# Binaries
|
||||
*.exe
|
||||
*.exe~
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
|
||||
# Build
|
||||
bin/
|
||||
dist/
|
||||
|
||||
# Environment
|
||||
.env
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
|
||||
# Dependency directories
|
||||
vendor/
|
||||
|
||||
# Test binary, built with go test -c
|
||||
*.test
|
||||
|
||||
# Output of the go coverage tool
|
||||
*.out
|
||||
|
||||
tmp/
|
||||
30
server/Dockerfile
Normal file
30
server/Dockerfile
Normal file
@@ -0,0 +1,30 @@
|
||||
# Use smaller Golang image
|
||||
FROM golang:alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install required dependencies (git for go mod)
|
||||
RUN apk add --no-cache git
|
||||
|
||||
# Copy and download dependencies
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download && go mod verify
|
||||
|
||||
# Copy remaining application files
|
||||
COPY . .
|
||||
|
||||
# Build the application as a statically linked binary
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -o main ./cmd/api
|
||||
|
||||
# Use minimal final image
|
||||
FROM alpine:latest
|
||||
|
||||
WORKDIR /app
|
||||
COPY --from=builder /app/main .
|
||||
COPY .env .
|
||||
|
||||
# Expose port
|
||||
EXPOSE 8080
|
||||
|
||||
# Run application
|
||||
CMD ["./main"]
|
||||
41
server/Makefile
Normal file
41
server/Makefile
Normal file
@@ -0,0 +1,41 @@
|
||||
.PHONY: build run test docker-build docker-run dev clean fmt lint vet staticcheck install-lint
|
||||
|
||||
APP_NAME=go-server
|
||||
|
||||
build:
|
||||
mkdir -p bin
|
||||
go build -o bin/app cmd/api/main.go
|
||||
|
||||
run:
|
||||
go run cmd/api/main.go
|
||||
|
||||
test:
|
||||
go test -v ./...
|
||||
|
||||
docker-build:
|
||||
docker build -t $(APP_NAME) .
|
||||
|
||||
docker-run:
|
||||
docker compose up -d
|
||||
|
||||
dev:
|
||||
air
|
||||
|
||||
clean:
|
||||
rm -rf bin/ tmp/
|
||||
|
||||
fmt:
|
||||
go fmt ./...
|
||||
|
||||
lint:
|
||||
golangci-lint run ./...
|
||||
|
||||
vet:
|
||||
go vet ./...
|
||||
|
||||
staticcheck:
|
||||
go install honnef.co/go/tools/cmd/staticcheck@latest
|
||||
staticcheck ./...
|
||||
|
||||
install-lint:
|
||||
go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest
|
||||
70
server/README.md
Normal file
70
server/README.md
Normal file
@@ -0,0 +1,70 @@
|
||||
# Go Server
|
||||
|
||||
A scalable backend boilerplate built with **Go**, **Gin**, **GORM**, **PostgreSQL**, and **Redis**.
|
||||
|
||||
---
|
||||
|
||||
## Technologies Used
|
||||
|
||||
- **Go** - Core language
|
||||
- **Gin** - HTTP framework
|
||||
- **GORM** - ORM for PostgreSQL
|
||||
- **Redis** - In-memory caching
|
||||
- **Docker** - Containerization (optional)
|
||||
|
||||
## Features
|
||||
|
||||
- **RESTful API** using Gin
|
||||
- **PostgreSQL + Redis** integration
|
||||
- **Modular service-based architecture**
|
||||
- **Middleware support** (CORS, Logging)
|
||||
- **Graceful shutdown handling**
|
||||
- **Environment variable configuration**
|
||||
|
||||
---
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Installation
|
||||
|
||||
1. Create a `.env` file and configure database credentials:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
2. Install dependencies:
|
||||
|
||||
```bash
|
||||
go mod tidy
|
||||
```
|
||||
|
||||
3. Run the database migrations (if applicable):
|
||||
|
||||
```bash
|
||||
go run scripts/migrate.go
|
||||
```
|
||||
|
||||
4. Start the server:
|
||||
|
||||
```bash
|
||||
go run main.go
|
||||
```
|
||||
|
||||
The server should be running at **http://localhost:8080**
|
||||
|
||||
---
|
||||
|
||||
## 📂 Project Structure
|
||||
|
||||
```
|
||||
├── config/ # Configuration files
|
||||
├── controllers/ # API Controllers
|
||||
├── models/ # Database Models
|
||||
├── routes/ # Route Handlers
|
||||
├── services/ # Business Logic Layer
|
||||
├── database/ # DB Connection & Migrations
|
||||
├── middleware/ # Middleware (CORS, Auth, Logging)
|
||||
├── main.go # Entry Point
|
||||
└── .env.example # Environment Config Sample
|
||||
```
|
||||
BIN
server/api
Executable file
BIN
server/api
Executable file
Binary file not shown.
97
server/cmd/api/main.go
Normal file
97
server/cmd/api/main.go
Normal file
@@ -0,0 +1,97 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"go-server/internal/config"
|
||||
"go-server/internal/database"
|
||||
"go-server/internal/models"
|
||||
"go-server/internal/routes"
|
||||
"go-server/internal/server"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func main() {
|
||||
fmt.Println("Starting server...")
|
||||
|
||||
// Load configuration
|
||||
cfg, err := config.LoadConfig()
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to load config: %v", err)
|
||||
}
|
||||
|
||||
// init dbs
|
||||
_ = database.InitDatabases(database.NewPostgresConfig(), database.RedisConfig(cfg.Redis))
|
||||
|
||||
// Initialize PostgreSQL
|
||||
db := database.GetPostgres()
|
||||
sqlDb, err := db.DB()
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to get DB connection: %v", err)
|
||||
}
|
||||
defer sqlDb.Close()
|
||||
|
||||
// Auto-migrate models to ensure GORM knows about table structures
|
||||
err = migrateModels(db)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to migrate models: %v", err)
|
||||
}
|
||||
|
||||
// Initialize Redis
|
||||
redisClient := database.GetRedis()
|
||||
defer redisClient.Close()
|
||||
|
||||
// Setup router
|
||||
router := routes.SetupRouter(db)
|
||||
|
||||
// Use the server abstraction
|
||||
srv := server.NewServer(router)
|
||||
|
||||
// Handle graceful shutdown
|
||||
quit := make(chan os.Signal, 1)
|
||||
signal.Notify(quit, os.Interrupt, syscall.SIGTERM)
|
||||
go func() {
|
||||
<-quit
|
||||
fmt.Println("Shutting down server...")
|
||||
|
||||
// Create shutdown context with a timeout
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Shutdown services gracefully
|
||||
if err := srv.Shutdown(ctx); err != nil {
|
||||
log.Fatalf("Server shutdown failed: %v", err)
|
||||
}
|
||||
|
||||
redisClient.Close()
|
||||
sqlDb.Close()
|
||||
fmt.Println("Server gracefully stopped")
|
||||
}()
|
||||
|
||||
// Start server
|
||||
port := cfg.Server.Port
|
||||
if err := srv.Start(port); err != nil {
|
||||
log.Fatalf("Failed to start server: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// migrateModels auto-migrates all models to ensure GORM knows about table structures
|
||||
func migrateModels(db *gorm.DB) error {
|
||||
return db.AutoMigrate(
|
||||
models.User{},
|
||||
models.Supplement{},
|
||||
models.Nutrient{},
|
||||
models.Category{},
|
||||
models.NutrientCategory{},
|
||||
models.SupplementNutrient{},
|
||||
models.Todo{},
|
||||
models.TodoCompletion{},
|
||||
)
|
||||
}
|
||||
66
server/compose.yaml
Normal file
66
server/compose.yaml
Normal file
@@ -0,0 +1,66 @@
|
||||
services:
|
||||
# app:
|
||||
# env_file:
|
||||
# - .env
|
||||
# build:
|
||||
# context: .
|
||||
# dockerfile: Dockerfile
|
||||
# ports:
|
||||
# - "${SERVER_PORT}:${SERVER_PORT}"
|
||||
# depends_on:
|
||||
# - postgres
|
||||
# - mongodb
|
||||
# - redis
|
||||
# environment:
|
||||
# # Server
|
||||
# - SERVER_PORT=${SERVER_PORT}
|
||||
# - SERVER_ENV=${SERVER_ENV}
|
||||
|
||||
# # Database
|
||||
# - DB_HOST=${DB_HOST}
|
||||
# - DB_PORT=${DB_PORT}
|
||||
# - DB_USER=${DB_USER}
|
||||
# - DB_PASSWORD=${DB_PASSWORD}
|
||||
# - DB_NAME=${DB_NAME}
|
||||
# - DB_SSL_MODE=${DB_SSL_MODE}
|
||||
|
||||
# # Redis
|
||||
# - REDIS_ADDR=${REDIS_ADDR}
|
||||
# - REDIS_PASSWORD=${REDIS_PASSWORD}
|
||||
# - REDIS_DB=${REDIS_DB}
|
||||
# networks:
|
||||
# - app-network
|
||||
# restart: unless-stopped
|
||||
|
||||
postgres:
|
||||
image: postgres:17-alpine
|
||||
environment:
|
||||
- POSTGRES_USER=${DB_USER}
|
||||
- POSTGRES_PASSWORD=${DB_PASSWORD}
|
||||
- POSTGRES_DB=${DB_NAME}
|
||||
ports:
|
||||
- "8553:5432"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
networks:
|
||||
- app-network
|
||||
restart: unless-stopped
|
||||
|
||||
redis:
|
||||
image: redis:alpine
|
||||
ports:
|
||||
- "8554:6379"
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
networks:
|
||||
- app-network
|
||||
restart: unless-stopped
|
||||
|
||||
networks:
|
||||
app-network:
|
||||
driver: bridge
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
mongodb_data:
|
||||
redis_data:
|
||||
69
server/go.mod
Normal file
69
server/go.mod
Normal file
@@ -0,0 +1,69 @@
|
||||
module go-server
|
||||
|
||||
go 1.23.0
|
||||
|
||||
toolchain go1.24.5
|
||||
|
||||
require github.com/spf13/viper v1.20.1
|
||||
|
||||
require (
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
||||
github.com/oklog/ulid/v2 v2.1.1 // indirect
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/bytedance/sonic v1.14.0 // indirect
|
||||
github.com/bytedance/sonic/loader v0.3.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.5 // indirect
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||
github.com/fsnotify/fsnotify v1.9.0
|
||||
github.com/gabriel-vasile/mimetype v1.4.9 // indirect
|
||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||
github.com/gin-gonic/gin v1.10.1
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.27.0
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.2.3
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/hibiken/asynq v0.25.1
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/pgx/v5 v5.7.5 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/redis/go-redis/v9 v9.11.0
|
||||
github.com/robfig/cron/v3 v3.0.1 // indirect
|
||||
github.com/sagikazarmark/locafero v0.9.0 // indirect
|
||||
github.com/sourcegraph/conc v0.3.0 // indirect
|
||||
github.com/spf13/afero v1.14.0 // indirect
|
||||
github.com/spf13/cast v1.9.2 // indirect
|
||||
github.com/spf13/pflag v1.0.7 // indirect
|
||||
github.com/subosito/gotenv v1.6.0 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.3.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
go.uber.org/zap v1.27.0
|
||||
golang.org/x/arch v0.19.0 // indirect
|
||||
golang.org/x/crypto v0.40.0
|
||||
golang.org/x/net v0.42.0 // indirect
|
||||
golang.org/x/sync v0.16.0 // indirect
|
||||
golang.org/x/sys v0.34.0 // indirect
|
||||
golang.org/x/text v0.27.0 // indirect
|
||||
golang.org/x/time v0.12.0 // indirect
|
||||
google.golang.org/protobuf v1.36.6 // indirect
|
||||
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
|
||||
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
gorm.io/driver/postgres v1.6.0
|
||||
gorm.io/gorm v1.30.1
|
||||
)
|
||||
160
server/go.sum
Normal file
160
server/go.sum
Normal file
@@ -0,0 +1,160 @@
|
||||
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
|
||||
github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ=
|
||||
github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA=
|
||||
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
||||
github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
|
||||
github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4=
|
||||
github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
||||
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY=
|
||||
github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok=
|
||||
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||
github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ=
|
||||
github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
|
||||
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.3 h1:kkGXqQOBSDDWRhWNXTFpqGSCMyh/PLnqUvMGJPDJDs0=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.3/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
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/hibiken/asynq v0.25.1 h1:phj028N0nm15n8O2ims+IvJ2gz4k2auvermngh9JhTw=
|
||||
github.com/hibiken/asynq v0.25.1/go.mod h1:pazWNOLBu0FEynQRBvHA26qdIKRSmfdIfUm4HdsLmXg=
|
||||
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=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.7.5 h1:JHGfMnQY+IEtGM63d+NGMjoRpysB2JBwDr5fsngwmJs=
|
||||
github.com/jackc/pgx/v5 v5.7.5/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s=
|
||||
github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ=
|
||||
github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/redis/go-redis/v9 v9.11.0 h1:E3S08Gl/nJNn5vkxd2i78wZxWAPNZgUNTp8WIJUAiIs=
|
||||
github.com/redis/go-redis/v9 v9.11.0/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw=
|
||||
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
||||
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||
github.com/sagikazarmark/locafero v0.9.0 h1:GbgQGNtTrEmddYDSAH9QLRyfAHY12md+8YFTqyMTC9k=
|
||||
github.com/sagikazarmark/locafero v0.9.0/go.mod h1:UBUyz37V+EdMS3hDF3QWIiVr/2dPrx49OMO0Bn0hJqk=
|
||||
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
|
||||
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
|
||||
github.com/spf13/afero v1.14.0 h1:9tH6MapGnn/j0eb0yIXiLjERO8RB6xIVZRDCX7PtqWA=
|
||||
github.com/spf13/afero v1.14.0/go.mod h1:acJQ8t0ohCGuMN3O+Pv0V0hgMxNYDlvdk+VTfyZmbYo=
|
||||
github.com/spf13/cast v1.9.2 h1:SsGfm7M8QOFtEzumm7UZrZdLLquNdzFYfIbEXntcFbE=
|
||||
github.com/spf13/cast v1.9.2/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
|
||||
github.com/spf13/pflag v1.0.7 h1:vN6T9TfwStFPFM5XzjsvmzZkLuaLX+HS+0SeFLRgU6M=
|
||||
github.com/spf13/pflag v1.0.7/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4=
|
||||
github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
|
||||
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
|
||||
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
|
||||
golang.org/x/arch v0.19.0 h1:LmbDQUodHThXE+htjrnmVD73M//D9GTH6wFZjyDkjyU=
|
||||
golang.org/x/arch v0.19.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
|
||||
golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
|
||||
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
|
||||
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
|
||||
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
|
||||
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
|
||||
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
|
||||
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
|
||||
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
|
||||
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
|
||||
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
|
||||
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
|
||||
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
|
||||
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk=
|
||||
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod h1:m7x9LTH6d71AHyAX77c9yqWCCa3UKHcVEj9y7hAtKDk=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df h1:n7WqCuqOuCbNr617RXOY0AWRXxgwEyPp2z+p0+hgMuE=
|
||||
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df/go.mod h1:LRQQ+SO6ZHR7tOkpBDuZnXENFzX8qRjMDMyPD6BRkCw=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
|
||||
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
|
||||
gorm.io/gorm v1.30.1 h1:lSHg33jJTBxs2mgJRfRZeLDG+WZaHYCk3Wtfl6Ngzo4=
|
||||
gorm.io/gorm v1.30.1/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE=
|
||||
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
|
||||
208
server/internal/config/config.go
Normal file
208
server/internal/config/config.go
Normal file
@@ -0,0 +1,208 @@
|
||||
// internal/config/config.go
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
|
||||
"github.com/fsnotify/fsnotify"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Server ServerConfig
|
||||
Database DatabaseConfig
|
||||
Redis RedisConfig
|
||||
SMTP SMTPConfig
|
||||
JWT JWTConfig
|
||||
}
|
||||
|
||||
type ServerConfig struct {
|
||||
Port string
|
||||
Env string
|
||||
}
|
||||
|
||||
type DatabaseConfig struct {
|
||||
Host string
|
||||
Port string
|
||||
User string
|
||||
Password string
|
||||
Name string
|
||||
SSLMode string
|
||||
}
|
||||
|
||||
type RedisConfig struct {
|
||||
Addr string
|
||||
Password string
|
||||
DB int
|
||||
}
|
||||
|
||||
type SMTPConfig struct {
|
||||
Host string
|
||||
Port int
|
||||
Username string
|
||||
Password string
|
||||
From string
|
||||
}
|
||||
|
||||
type JWTConfig struct {
|
||||
Secret string
|
||||
PublicKey string
|
||||
Expiry string
|
||||
}
|
||||
|
||||
// Package-level state for a single, watched config instance
|
||||
var (
|
||||
vip *viper.Viper
|
||||
cfgCache *Config
|
||||
cfgMu sync.RWMutex
|
||||
initOnce sync.Once
|
||||
initErr error
|
||||
|
||||
listeners []func(*Config)
|
||||
listenersMu sync.RWMutex
|
||||
)
|
||||
|
||||
// LoadConfig returns the current configuration. On first call it initializes
|
||||
// Viper, loads the config from a deterministic location, and starts watching
|
||||
// for changes. Subsequent calls are cheap and thread-safe.
|
||||
func LoadConfig() (*Config, error) {
|
||||
initOnce.Do(func() {
|
||||
vip = viper.New()
|
||||
configureViper(vip)
|
||||
// Initial read must succeed; later changes are handled via WatchConfig
|
||||
if err := vip.ReadInConfig(); err != nil {
|
||||
initErr = fmt.Errorf("failed to load config: %w", err)
|
||||
return
|
||||
}
|
||||
// Build cache from initial values
|
||||
cfg := buildFromViper(vip)
|
||||
cfgMu.Lock()
|
||||
cfgCache = cfg
|
||||
cfgMu.Unlock()
|
||||
|
||||
// Watch for changes and update cache atomically
|
||||
vip.WatchConfig()
|
||||
vip.OnConfigChange(func(_ fsnotify.Event) {
|
||||
// viper already re-reads on change; rebuild view and swap
|
||||
newCfg := buildFromViper(vip)
|
||||
cfgMu.Lock()
|
||||
cfgCache = newCfg
|
||||
cfgMu.Unlock()
|
||||
notifyListeners(newCfg)
|
||||
})
|
||||
})
|
||||
|
||||
if initErr != nil {
|
||||
return nil, initErr
|
||||
}
|
||||
|
||||
cfgMu.RLock()
|
||||
defer cfgMu.RUnlock()
|
||||
return cloneConfig(cfgCache), nil
|
||||
}
|
||||
|
||||
// DSN returns the Data Source Name for PostgreSQL connection
|
||||
func (db DatabaseConfig) DSN() string {
|
||||
return fmt.Sprintf(
|
||||
"postgres://%s:%s@%s:%s/%s?sslmode=%s",
|
||||
db.User, db.Password, db.Host, db.Port, db.Name, db.SSLMode,
|
||||
)
|
||||
}
|
||||
|
||||
// RegisterChangeListener registers a callback invoked whenever the configuration
|
||||
// file changes and the in-memory configuration is updated.
|
||||
func RegisterChangeListener(listener func(*Config)) {
|
||||
listenersMu.Lock()
|
||||
defer listenersMu.Unlock()
|
||||
listeners = append(listeners, listener)
|
||||
}
|
||||
|
||||
// --- internal helpers ---
|
||||
|
||||
func configureViper(v *viper.Viper) {
|
||||
// Allow env vars to override any key (expects exact key names used below)
|
||||
v.AutomaticEnv()
|
||||
|
||||
if path, ok := resolveConfigFilePath(); ok {
|
||||
v.SetConfigFile(path)
|
||||
return
|
||||
}
|
||||
// Fallback to looking for a .env in stable, nearby locations.
|
||||
v.SetConfigName(".env")
|
||||
v.SetConfigType("env")
|
||||
v.AddConfigPath(".")
|
||||
v.AddConfigPath("..")
|
||||
}
|
||||
|
||||
func resolveConfigFilePath() (string, bool) {
|
||||
if p := os.Getenv("CONFIG_FILE"); p != "" {
|
||||
return p, true
|
||||
}
|
||||
cwd, _ := os.Getwd()
|
||||
candidates := []string{
|
||||
filepath.Join(cwd, ".env"),
|
||||
filepath.Join(cwd, "go-server", ".env"),
|
||||
filepath.Join(cwd, "..", ".env"),
|
||||
filepath.Join(cwd, "..", "go-server", ".env"),
|
||||
}
|
||||
for _, c := range candidates {
|
||||
if info, err := os.Stat(c); err == nil && !info.IsDir() {
|
||||
return c, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func buildFromViper(v *viper.Viper) *Config {
|
||||
return &Config{
|
||||
Server: ServerConfig{
|
||||
Port: v.GetString("SERVER_PORT"),
|
||||
Env: v.GetString("SERVER_ENV"),
|
||||
},
|
||||
Database: DatabaseConfig{
|
||||
Host: v.GetString("DB_HOST"),
|
||||
Port: v.GetString("DB_PORT"),
|
||||
User: v.GetString("DB_USER"),
|
||||
Password: v.GetString("DB_PASSWORD"),
|
||||
Name: v.GetString("DB_NAME"),
|
||||
SSLMode: v.GetString("DB_SSL_MODE"),
|
||||
},
|
||||
Redis: RedisConfig{
|
||||
Addr: v.GetString("REDIS_ADDR"),
|
||||
Password: v.GetString("REDIS_PASSWORD"),
|
||||
DB: v.GetInt("REDIS_DB"),
|
||||
},
|
||||
SMTP: SMTPConfig{
|
||||
Host: v.GetString("SMTP_HOST"),
|
||||
Port: v.GetInt("SMTP_PORT"),
|
||||
Username: v.GetString("SMTP_USERNAME"),
|
||||
Password: v.GetString("SMTP_PASSWORD"),
|
||||
From: v.GetString("SMTP_FROM"),
|
||||
},
|
||||
JWT: JWTConfig{
|
||||
Secret: v.GetString("JWT_SECRET"),
|
||||
Expiry: v.GetString("JWT_EXPIRY"),
|
||||
PublicKey: v.GetString("JWT_PUBLIC_KEY"),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func cloneConfig(c *Config) *Config {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
cc := *c
|
||||
return &cc
|
||||
}
|
||||
|
||||
func notifyListeners(c *Config) {
|
||||
listenersMu.RLock()
|
||||
defer listenersMu.RUnlock()
|
||||
for _, l := range listeners {
|
||||
// Call listeners in-goroutine to avoid blocking the watcher
|
||||
go l(cloneConfig(c))
|
||||
}
|
||||
}
|
||||
34
server/internal/database/db.go
Normal file
34
server/internal/database/db.go
Normal file
@@ -0,0 +1,34 @@
|
||||
// internal/database/db.go
|
||||
package database
|
||||
|
||||
import (
|
||||
"github.com/redis/go-redis/v9"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var (
|
||||
pgDB *gorm.DB
|
||||
redisClient *redis.Client
|
||||
)
|
||||
|
||||
func InitDatabases(pgConfig PostgresConfig, redisConfig RedisConfig) error {
|
||||
var err error
|
||||
pgDB, err = NewPostgresConnection(pgConfig)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
redisClient, err = NewRedisConnection(redisConfig)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func GetPostgres() *gorm.DB {
|
||||
return pgDB
|
||||
}
|
||||
|
||||
func GetRedis() *redis.Client {
|
||||
return redisClient
|
||||
}
|
||||
64
server/internal/database/postgresql.go
Normal file
64
server/internal/database/postgresql.go
Normal file
@@ -0,0 +1,64 @@
|
||||
// internal/database/postgresql.go
|
||||
package database
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"go-server/internal/config"
|
||||
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type PostgresConfig struct {
|
||||
Host string
|
||||
Port string
|
||||
User string
|
||||
Password string
|
||||
DBName string
|
||||
SSLMode string
|
||||
}
|
||||
|
||||
func NewPostgresConfig() PostgresConfig {
|
||||
cfg, err := config.LoadConfig()
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to load postgres config: %v", err)
|
||||
}
|
||||
|
||||
return PostgresConfig{
|
||||
Host: cfg.Database.Host,
|
||||
Port: cfg.Database.Port,
|
||||
User: cfg.Database.User,
|
||||
Password: cfg.Database.Password,
|
||||
DBName: cfg.Database.Name,
|
||||
SSLMode: cfg.Database.SSLMode,
|
||||
}
|
||||
}
|
||||
|
||||
func NewPostgresConnection(config PostgresConfig) (*gorm.DB, error) {
|
||||
dsn := fmt.Sprintf("host=%s user=%s password=%s dbname=%s port=%s sslmode=%s",
|
||||
config.Host, config.User, config.Password, config.DBName, config.Port, config.SSLMode)
|
||||
|
||||
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{
|
||||
PrepareStmt: true,
|
||||
SkipDefaultTransaction: true,
|
||||
// Logger can be enabled if needed for debugging
|
||||
// Logger: logger.Default.LogMode(logger.Info),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to connect to database: %w", err)
|
||||
}
|
||||
|
||||
// Configure database/sql connection pool
|
||||
sqlDB, err := db.DB()
|
||||
if err == nil {
|
||||
sqlDB.SetMaxIdleConns(10)
|
||||
sqlDB.SetMaxOpenConns(50)
|
||||
sqlDB.SetConnMaxLifetime(60 * time.Minute)
|
||||
sqlDB.SetConnMaxIdleTime(10 * time.Minute)
|
||||
}
|
||||
|
||||
return db, nil
|
||||
}
|
||||
31
server/internal/database/redis.go
Normal file
31
server/internal/database/redis.go
Normal file
@@ -0,0 +1,31 @@
|
||||
// internal/database/redis.go
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
type RedisConfig struct {
|
||||
Addr string
|
||||
Password string
|
||||
DB int
|
||||
}
|
||||
|
||||
func NewRedisConnection(config RedisConfig) (*redis.Client, error) {
|
||||
client := redis.NewClient(&redis.Options{
|
||||
Addr: config.Addr,
|
||||
Password: config.Password,
|
||||
DB: config.DB,
|
||||
})
|
||||
|
||||
// Test connection
|
||||
ctx := context.Background()
|
||||
if err := client.Ping(ctx).Err(); err != nil {
|
||||
return nil, fmt.Errorf("failed to connect to redis: %w", err)
|
||||
}
|
||||
|
||||
return client, nil
|
||||
}
|
||||
64
server/internal/email/sender.go
Normal file
64
server/internal/email/sender.go
Normal file
@@ -0,0 +1,64 @@
|
||||
// internal/email/sender.go
|
||||
package email
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"html/template"
|
||||
|
||||
"gopkg.in/gomail.v2"
|
||||
)
|
||||
|
||||
type EmailConfig struct {
|
||||
Host string
|
||||
Port int
|
||||
Username string
|
||||
Password string
|
||||
From string
|
||||
}
|
||||
|
||||
type EmailService struct {
|
||||
config EmailConfig
|
||||
dialer *gomail.Dialer
|
||||
templates *template.Template
|
||||
}
|
||||
|
||||
func NewEmailService(config EmailConfig) (*EmailService, error) {
|
||||
// Load email templates
|
||||
templates, err := template.ParseGlob("internal/email/templates/*.html")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load email templates: %w", err)
|
||||
}
|
||||
|
||||
dialer := gomail.NewDialer(
|
||||
config.Host,
|
||||
config.Port,
|
||||
config.Username,
|
||||
config.Password,
|
||||
)
|
||||
|
||||
return &EmailService{
|
||||
config: config,
|
||||
dialer: dialer,
|
||||
templates: templates,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *EmailService) SendEmail(to []string, subject string, templateName string, data interface{}) error {
|
||||
var body bytes.Buffer
|
||||
if err := s.templates.ExecuteTemplate(&body, templateName, data); err != nil {
|
||||
return fmt.Errorf("failed to execute template: %w", err)
|
||||
}
|
||||
|
||||
m := gomail.NewMessage()
|
||||
m.SetHeader("From", s.config.From)
|
||||
m.SetHeader("To", to...)
|
||||
m.SetHeader("Subject", subject)
|
||||
m.SetBody("text/html", body.String())
|
||||
|
||||
if err := s.dialer.DialAndSend(m); err != nil {
|
||||
return fmt.Errorf("failed to send email: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
41
server/internal/email/templates/base.html
Normal file
41
server/internal/email/templates/base.html
Normal file
@@ -0,0 +1,41 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>{{.Subject}}</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
.header {
|
||||
background-color: #f8f9fa;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.content {
|
||||
padding: 20px;
|
||||
}
|
||||
.footer {
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>{{.Title}}</h1>
|
||||
</div>
|
||||
<div class="content">{{.Content}}</div>
|
||||
<div class="footer">
|
||||
<p>© {{.Year}} Your Company. All rights reserved.</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
25
server/internal/handlers/category_handler.go
Normal file
25
server/internal/handlers/category_handler.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"go-server/internal/service"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type CategoryController struct {
|
||||
categoryService *service.CategoryService
|
||||
}
|
||||
|
||||
func NewCategoryController(categoryService *service.CategoryService) *CategoryController {
|
||||
return &CategoryController{categoryService: categoryService}
|
||||
}
|
||||
|
||||
func (h *CategoryController) GetAll(c *gin.Context) {
|
||||
categories, err := h.categoryService.GetAll(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, categories)
|
||||
}
|
||||
93
server/internal/handlers/daily_overview_handler.go
Normal file
93
server/internal/handlers/daily_overview_handler.go
Normal file
@@ -0,0 +1,93 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"go-server/internal/service"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type DailyOverviewController struct {
|
||||
service *service.DailyOverviewService
|
||||
}
|
||||
|
||||
func NewDailyOverviewController(service *service.DailyOverviewService) *DailyOverviewController {
|
||||
return &DailyOverviewController{
|
||||
service: service,
|
||||
}
|
||||
}
|
||||
|
||||
// GetDailyOverview returns comprehensive daily nutrient overview
|
||||
// GET /api/daily-overview/overview
|
||||
func (h *DailyOverviewController) GetDailyOverview(c *gin.Context) {
|
||||
overview, err := h.service.GetDailyOverview(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get daily overview"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, overview)
|
||||
}
|
||||
|
||||
// GetNutrientTotals returns just the aggregated nutrient totals
|
||||
// GET /api/daily-overview/totals
|
||||
func (h *DailyOverviewController) GetNutrientTotals(c *gin.Context) {
|
||||
totals, err := h.service.GetNutrientTotals(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get nutrient totals"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, totals)
|
||||
}
|
||||
|
||||
// GetSupplementBreakdown returns detailed breakdown by supplement
|
||||
// GET /api/daily-overview/breakdown
|
||||
func (h *DailyOverviewController) GetSupplementBreakdown(c *gin.Context) {
|
||||
breakdown, err := h.service.GetSupplementBreakdown(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get supplement breakdown"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, breakdown)
|
||||
}
|
||||
|
||||
// ExecuteCustomQuery allows executing custom SQL queries via API
|
||||
// POST /api/daily-overview/query
|
||||
// Body: {"query": "SELECT ...", "args": [...]}
|
||||
func (h *DailyOverviewController) ExecuteCustomQuery(c *gin.Context) {
|
||||
var request struct {
|
||||
Query string `json:"query"`
|
||||
Args []interface{} `json:"args"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&request); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
|
||||
return
|
||||
}
|
||||
|
||||
if request.Query == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Query is required"})
|
||||
return
|
||||
}
|
||||
|
||||
// Basic security: only allow SELECT statements
|
||||
queryUpper := strings.ToUpper(strings.TrimSpace(request.Query))
|
||||
if !strings.HasPrefix(queryUpper, "SELECT") {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Only SELECT queries are allowed"})
|
||||
return
|
||||
}
|
||||
|
||||
results, err := h.service.ExecuteCustomQuery(c.Request.Context(), request.Query, request.Args...)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to execute query"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"results": results,
|
||||
"count": len(results),
|
||||
})
|
||||
}
|
||||
26
server/internal/handlers/nutrient_handler.go
Normal file
26
server/internal/handlers/nutrient_handler.go
Normal file
@@ -0,0 +1,26 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"go-server/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type NutrientController struct {
|
||||
service *service.NutrientService
|
||||
}
|
||||
|
||||
func NewNutrientController(service *service.NutrientService) *NutrientController {
|
||||
return &NutrientController{service}
|
||||
}
|
||||
|
||||
func (c *NutrientController) GetAll(ctx *gin.Context) {
|
||||
nutrients, err := c.service.GetAll(ctx)
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
ctx.JSON(http.StatusOK, nutrients)
|
||||
}
|
||||
35
server/internal/handlers/supplement_handler.go
Normal file
35
server/internal/handlers/supplement_handler.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"go-server/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type SupplementController struct {
|
||||
service *service.SupplementService
|
||||
}
|
||||
|
||||
func NewSupplementController(service *service.SupplementService) *SupplementController {
|
||||
return &SupplementController{service}
|
||||
}
|
||||
|
||||
func (c *SupplementController) GetAll(ctx *gin.Context) {
|
||||
supplements, err := c.service.GetAll(ctx)
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
ctx.JSON(http.StatusOK, supplements)
|
||||
}
|
||||
|
||||
func (c *SupplementController) GetDailySupplementsOverview(ctx *gin.Context) {
|
||||
supplements, err := c.service.GetDailySupplementsOverview(ctx)
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
ctx.JSON(http.StatusOK, supplements)
|
||||
}
|
||||
269
server/internal/handlers/todo_handler.go
Normal file
269
server/internal/handlers/todo_handler.go
Normal file
@@ -0,0 +1,269 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"go-server/internal/models"
|
||||
"go-server/internal/service"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type TodoController struct {
|
||||
service *service.TodoService
|
||||
}
|
||||
|
||||
func NewTodoController(service *service.TodoService) *TodoController {
|
||||
return &TodoController{
|
||||
service: service,
|
||||
}
|
||||
}
|
||||
|
||||
// CreateTodo creates a new todo
|
||||
// POST /api/todo/create
|
||||
func (h *TodoController) CreateTodo(c *gin.Context) {
|
||||
userID := getUserIDFromContext(c) // You'll need to implement this helper
|
||||
if userID.IsZero() {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
|
||||
return
|
||||
}
|
||||
|
||||
var req service.CreateTodoRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
|
||||
return
|
||||
}
|
||||
|
||||
todo, err := h.service.CreateTodo(c.Request.Context(), userID, req)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create todo"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, todo)
|
||||
}
|
||||
|
||||
// GetTodos gets all todos with stats for the authenticated user
|
||||
// GET /api/todo/list
|
||||
func (h *TodoController) GetTodos(c *gin.Context) {
|
||||
userID := getUserIDFromContext(c)
|
||||
if userID.IsZero() {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
|
||||
return
|
||||
}
|
||||
|
||||
todos, err := h.service.GetTodosWithStats(c.Request.Context(), userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get todos"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, todos)
|
||||
}
|
||||
|
||||
// GetTodaysSummary gets today's todo summary
|
||||
// GET /api/todo/today
|
||||
func (h *TodoController) GetTodaysSummary(c *gin.Context) {
|
||||
userID := getUserIDFromContext(c)
|
||||
if userID.IsZero() {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
|
||||
return
|
||||
}
|
||||
|
||||
summary, err := h.service.GetTodaysSummary(c.Request.Context(), userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get today's summary"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, summary)
|
||||
}
|
||||
|
||||
// UpdateTodo updates a todo
|
||||
// PUT /api/todo/:id
|
||||
func (h *TodoController) UpdateTodo(c *gin.Context) {
|
||||
userID := getUserIDFromContext(c)
|
||||
if userID.IsZero() {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
|
||||
return
|
||||
}
|
||||
|
||||
todoIDStr := c.Param("id")
|
||||
todoID, err := models.ParseULID(todoIDStr)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid todo ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var req service.UpdateTodoRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
|
||||
return
|
||||
}
|
||||
|
||||
todo, err := h.service.UpdateTodo(c.Request.Context(), todoID, userID, req)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update todo"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, todo)
|
||||
}
|
||||
|
||||
// DeleteTodo deletes a todo
|
||||
// DELETE /api/todo/:id
|
||||
func (h *TodoController) DeleteTodo(c *gin.Context) {
|
||||
userID := getUserIDFromContext(c)
|
||||
if userID.IsZero() {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
|
||||
return
|
||||
}
|
||||
|
||||
todoIDStr := c.Param("id")
|
||||
todoID, err := models.ParseULID(todoIDStr)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid todo ID"})
|
||||
return
|
||||
}
|
||||
|
||||
err = h.service.DeleteTodo(c.Request.Context(), todoID, userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete todo"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Todo deleted successfully"})
|
||||
}
|
||||
|
||||
// CompleteTodo marks a todo as completed
|
||||
// POST /api/todo/:id/complete
|
||||
func (h *TodoController) CompleteTodo(c *gin.Context) {
|
||||
userID := getUserIDFromContext(c)
|
||||
if userID.IsZero() {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
|
||||
return
|
||||
}
|
||||
|
||||
todoIDStr := c.Param("id")
|
||||
todoID, err := models.ParseULID(todoIDStr)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid todo ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var req service.CompleteTodoRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
|
||||
return
|
||||
}
|
||||
|
||||
err = h.service.CompleteTodo(c.Request.Context(), todoID, userID, req)
|
||||
if err != nil {
|
||||
if err.Error() == "todo already completed today" {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "Todo already completed today"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to complete todo"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Todo completed successfully"})
|
||||
}
|
||||
|
||||
// GetActivityLog gets the user's activity log
|
||||
// GET /api/todo/activity
|
||||
func (h *TodoController) GetActivityLog(c *gin.Context) {
|
||||
userID := getUserIDFromContext(c)
|
||||
if userID.IsZero() {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
|
||||
return
|
||||
}
|
||||
|
||||
// Parse pagination parameters
|
||||
limitStr := c.DefaultQuery("limit", "50")
|
||||
offsetStr := c.DefaultQuery("offset", "0")
|
||||
|
||||
limit, err := strconv.Atoi(limitStr)
|
||||
if err != nil {
|
||||
limit = 50
|
||||
}
|
||||
|
||||
offset, err := strconv.Atoi(offsetStr)
|
||||
if err != nil {
|
||||
offset = 0
|
||||
}
|
||||
|
||||
activities, err := h.service.GetActivityLog(c.Request.Context(), userID, limit, offset)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get activity log"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, activities)
|
||||
}
|
||||
|
||||
// GetActivityLogByDate gets activity log for a specific date
|
||||
// GET /api/todo/activity/:date
|
||||
func (h *TodoController) GetActivityLogByDate(c *gin.Context) {
|
||||
userID := getUserIDFromContext(c)
|
||||
if userID.IsZero() {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
|
||||
return
|
||||
}
|
||||
|
||||
dateStr := c.Param("date")
|
||||
date, err := time.Parse("2006-01-02", dateStr)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid date format. Use YYYY-MM-DD"})
|
||||
return
|
||||
}
|
||||
|
||||
activities, err := h.service.GetActivityLogByDate(c.Request.Context(), userID, date)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get activity log"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, activities)
|
||||
}
|
||||
|
||||
// GetWeeklySummary gets a weekly summary of todo completions
|
||||
// GET /api/todo/weekly
|
||||
func (h *TodoController) GetWeeklySummary(c *gin.Context) {
|
||||
userID := getUserIDFromContext(c)
|
||||
if userID.IsZero() {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
|
||||
return
|
||||
}
|
||||
|
||||
summary, err := h.service.GetWeeklySummary(c.Request.Context(), userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get weekly summary"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, summary)
|
||||
}
|
||||
|
||||
// Helper function to get user ID from context
|
||||
// You'll need to implement this based on your authentication middleware
|
||||
func getUserIDFromContext(c *gin.Context) models.ULID {
|
||||
testUserId := models.MustParseULID("01K54QBS528HKQDF985XNW2J6R")
|
||||
return testUserId
|
||||
// This is a placeholder - implement based on your auth system
|
||||
// For example, if you store the user in context after JWT validation:
|
||||
|
||||
// userInterface, exists := c.Get("user")
|
||||
// if !exists {
|
||||
// return models.ULID{} // Return zero value
|
||||
// }
|
||||
|
||||
// user, ok := userInterface.(*models.User)
|
||||
// if !ok {
|
||||
// return models.ULID{} // Return zero value
|
||||
// }
|
||||
|
||||
// return user.ID
|
||||
}
|
||||
|
||||
81
server/internal/handlers/user_handler.go
Normal file
81
server/internal/handlers/user_handler.go
Normal file
@@ -0,0 +1,81 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"go-server/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type AdminController struct {
|
||||
service *service.UserService
|
||||
}
|
||||
|
||||
func NewAdminController(service *service.UserService) *AdminController {
|
||||
return &AdminController{service}
|
||||
}
|
||||
|
||||
func (uc *AdminController) GetUserByID(c *gin.Context) {
|
||||
id := uuid.MustParse(c.Param("id"))
|
||||
user, err := uc.service.GetByID(c, id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, user)
|
||||
}
|
||||
|
||||
func (uc *AdminController) GetUsers(c *gin.Context) {
|
||||
users, err := uc.service.GetAll(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, users)
|
||||
}
|
||||
|
||||
func (uc *AdminController) CreateUser(c *gin.Context) {
|
||||
var input service.CreateUserInput
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request"})
|
||||
return
|
||||
}
|
||||
|
||||
user, err := uc.service.Create(c, input)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, user)
|
||||
}
|
||||
|
||||
func (uc *AdminController) DeleteUser(c *gin.Context) {
|
||||
id := uuid.MustParse(c.Param("id"))
|
||||
err := uc.service.Delete(c, id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "User deleted successfully"})
|
||||
}
|
||||
|
||||
func (uc *AdminController) ChangePassword(c *gin.Context) {
|
||||
id := uuid.MustParse(c.Param("id"))
|
||||
var input service.ChangePasswordInput
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request"})
|
||||
return
|
||||
}
|
||||
err := uc.service.ChangePassword(c, id, input)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Password changed successfully"})
|
||||
}
|
||||
205
server/internal/middleware/auth.go
Normal file
205
server/internal/middleware/auth.go
Normal file
@@ -0,0 +1,205 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rsa"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"go-server/internal/config"
|
||||
"go-server/internal/database"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// AuthRequired is a middleware to validate JWT token
|
||||
func AuthRequired() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
jwtHeader := c.GetHeader("x-jwt")
|
||||
if jwtHeader == "" {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "authorization header required"})
|
||||
return
|
||||
}
|
||||
|
||||
// Validate the token
|
||||
claims, err := validateToken(jwtHeader)
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Attach user ID to the context
|
||||
c.Set("userID", claims.UserID)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// JWTClaims represents the JWT claims
|
||||
type JWTClaims struct {
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
var (
|
||||
publicKeyMu sync.RWMutex
|
||||
publicKey *rsa.PublicKey
|
||||
)
|
||||
|
||||
func init() {
|
||||
// Initialize cached public key at startup
|
||||
if cfg, err := config.LoadConfig(); err == nil {
|
||||
if pk, err := parsePublicKeyFromConfig(cfg.JWT.PublicKey); err == nil {
|
||||
publicKeyMu.Lock()
|
||||
publicKey = pk
|
||||
publicKeyMu.Unlock()
|
||||
}
|
||||
}
|
||||
// Update cached key on config changes
|
||||
config.RegisterChangeListener(func(c *config.Config) {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
if pk, err := parsePublicKeyFromConfig(c.JWT.PublicKey); err == nil {
|
||||
publicKeyMu.Lock()
|
||||
publicKey = pk
|
||||
publicKeyMu.Unlock()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func parsePublicKeyFromConfig(publicKeyB64 string) (*rsa.PublicKey, error) {
|
||||
if publicKeyB64 == "" {
|
||||
return nil, errors.New("missing JWT public key")
|
||||
}
|
||||
publicKeyBytes, err := base64.StdEncoding.DecodeString(publicKeyB64)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decode public key: %w", err)
|
||||
}
|
||||
pk, err := jwt.ParseRSAPublicKeyFromPEM(publicKeyBytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse public key: %w", err)
|
||||
}
|
||||
return pk, nil
|
||||
}
|
||||
|
||||
// validateToken validates and parses the JWT token
|
||||
func validateToken(tokenStr string) (*JWTClaims, error) {
|
||||
// 1) Try Redis cache first
|
||||
if claims := tryGetCachedClaims(tokenStr); claims != nil {
|
||||
// Double-check expiration in case clock skew and guard rails
|
||||
if claims.ExpiresAt != nil && claims.ExpiresAt.Before(time.Now()) {
|
||||
return nil, errors.New("token expired")
|
||||
}
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
// 2) Validate signature using cached public key
|
||||
publicKeyMu.RLock()
|
||||
pk := publicKey
|
||||
publicKeyMu.RUnlock()
|
||||
if pk == nil {
|
||||
return nil, errors.New("JWT public key not initialized")
|
||||
}
|
||||
|
||||
token, err := jwt.ParseWithClaims(tokenStr, &JWTClaims{}, func(token *jwt.Token) (interface{}, error) {
|
||||
if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok {
|
||||
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
|
||||
}
|
||||
return pk, nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
claims, ok := token.Claims.(*JWTClaims)
|
||||
if !ok || !token.Valid {
|
||||
return nil, errors.New("invalid token")
|
||||
}
|
||||
|
||||
// Check token expiration
|
||||
if claims.ExpiresAt.Before(time.Now()) {
|
||||
return nil, errors.New("token expired")
|
||||
}
|
||||
|
||||
// 3) Store in Redis cache with TTL until expiry
|
||||
cacheClaims(tokenStr, claims)
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
// --- Redis cache helpers ---
|
||||
|
||||
const jwtCachePrefix = "jwt:cache:"
|
||||
|
||||
type cachedClaims struct {
|
||||
UserID string `json:"user_id"`
|
||||
Exp int64 `json:"exp"`
|
||||
}
|
||||
|
||||
func tryGetCachedClaims(tokenStr string) *JWTClaims {
|
||||
r := database.GetRedis()
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
ctx := context.Background()
|
||||
key := jwtCachePrefix + sha256Hex(tokenStr)
|
||||
raw, err := r.Get(ctx, key).Bytes()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var cc cachedClaims
|
||||
if err := json.Unmarshal(raw, &cc); err != nil {
|
||||
return nil
|
||||
}
|
||||
if cc.Exp <= time.Now().Unix() {
|
||||
return nil
|
||||
}
|
||||
uid, err := uuid.Parse(cc.UserID)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return &JWTClaims{
|
||||
UserID: uid,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(time.Unix(cc.Exp, 0)),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func cacheClaims(tokenStr string, claims *JWTClaims) {
|
||||
if claims == nil || claims.ExpiresAt == nil {
|
||||
return
|
||||
}
|
||||
ttl := time.Until(claims.ExpiresAt.Time)
|
||||
if ttl <= 0 {
|
||||
return
|
||||
}
|
||||
r := database.GetRedis()
|
||||
if r == nil {
|
||||
return
|
||||
}
|
||||
ctx := context.Background()
|
||||
key := jwtCachePrefix + sha256Hex(tokenStr)
|
||||
payload, err := json.Marshal(cachedClaims{
|
||||
UserID: claims.UserID.String(),
|
||||
Exp: claims.ExpiresAt.Unix(),
|
||||
})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_ = r.Set(ctx, key, payload, ttl).Err()
|
||||
}
|
||||
|
||||
func sha256Hex(s string) string {
|
||||
sum := sha256.Sum256([]byte(s))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
20
server/internal/middleware/cors.go
Normal file
20
server/internal/middleware/cors.go
Normal file
@@ -0,0 +1,20 @@
|
||||
// internal/middleware/cors.go
|
||||
package middleware
|
||||
|
||||
import "github.com/gin-gonic/gin"
|
||||
|
||||
func CORS() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
c.Writer.Header().Set("Access-Control-Allow-Credentials", "true")
|
||||
c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, accept, origin, Cache-Control, X-Requested-With, x-jwt")
|
||||
c.Writer.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS, GET, PUT, DELETE")
|
||||
|
||||
if c.Request.Method == "OPTIONS" {
|
||||
c.AbortWithStatus(204)
|
||||
return
|
||||
}
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
41
server/internal/middleware/logger.go
Normal file
41
server/internal/middleware/logger.go
Normal file
@@ -0,0 +1,41 @@
|
||||
// internal/middleware/logger.go
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"go-server/pkg/logger"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// Logger provides lightweight structured logging and response time header
|
||||
func Logger() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
start := time.Now()
|
||||
|
||||
c.Next()
|
||||
|
||||
latency := time.Since(start)
|
||||
status := c.Writer.Status()
|
||||
method := c.Request.Method
|
||||
path := c.Request.URL.Path
|
||||
ip := c.ClientIP()
|
||||
ua := c.Request.UserAgent()
|
||||
|
||||
// Expose response time for clients/benchmarks
|
||||
c.Header("X-Response-Time", strconv.FormatInt(latency.Microseconds(), 10)+"us")
|
||||
|
||||
// Structured log (zap is very fast and minimally blocking)
|
||||
logger.Info("http_request",
|
||||
zap.Int("status", status),
|
||||
zap.String("method", method),
|
||||
zap.String("path", path),
|
||||
zap.Int64("latency_us", latency.Microseconds()),
|
||||
zap.String("ip", ip),
|
||||
zap.String("user_agent", ua),
|
||||
)
|
||||
}
|
||||
}
|
||||
21
server/internal/models/base.go
Normal file
21
server/internal/models/base.go
Normal file
@@ -0,0 +1,21 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type BaseModel struct {
|
||||
ID ULID `gorm:"primaryKey" json:"id" db:"id" `
|
||||
CreatedAt time.Time `json:"createdAt" db:"created_at"`
|
||||
UpdatedAt time.Time `json:"updatedAt" db:"updated_at"`
|
||||
}
|
||||
|
||||
// BeforeCreate is a GORM hook that runs before creating a record
|
||||
func (b *BaseModel) BeforeCreate(tx *gorm.DB) error {
|
||||
if b.ID.IsZero() {
|
||||
b.ID = GenerateULID()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
11
server/internal/models/category.go
Normal file
11
server/internal/models/category.go
Normal file
@@ -0,0 +1,11 @@
|
||||
package models
|
||||
|
||||
func (Category) TableName() string {
|
||||
return "categories"
|
||||
}
|
||||
|
||||
type Category struct {
|
||||
BaseModel
|
||||
Name string `json:"name" db:"name"`
|
||||
Description string `json:"description" db:"description"`
|
||||
}
|
||||
13
server/internal/models/nutrient.go
Normal file
13
server/internal/models/nutrient.go
Normal file
@@ -0,0 +1,13 @@
|
||||
package models
|
||||
|
||||
func (Nutrient) TableName() string {
|
||||
return "nutrients"
|
||||
}
|
||||
|
||||
type Nutrient struct {
|
||||
BaseModel
|
||||
Name string `json:"name" db:"name"`
|
||||
Description string `json:"description" db:"description"`
|
||||
Supplements []Supplement `json:"supplements" db:"supplements" gorm:"many2many:supplement_nutrients;"`
|
||||
Categories []Category `json:"categories" db:"categories" gorm:"many2many:nutrient_categories;"`
|
||||
}
|
||||
13
server/internal/models/nutrient_category.go
Normal file
13
server/internal/models/nutrient_category.go
Normal file
@@ -0,0 +1,13 @@
|
||||
package models
|
||||
|
||||
func (NutrientCategory) TableName() string {
|
||||
return "nutrient_categories"
|
||||
}
|
||||
|
||||
type NutrientCategory struct {
|
||||
BaseModel
|
||||
CategoryID ULID `json:"categoryId" db:"category_id"`
|
||||
NutrientID ULID `json:"nutrientId" db:"nutrient_id"`
|
||||
Category Category `json:"category" db:"category" gorm:"foreignKey:CategoryID"`
|
||||
Nutrient Nutrient `json:"nutrient" db:"nutrient" gorm:"foreignKey:NutrientID"`
|
||||
}
|
||||
21
server/internal/models/supplement.go
Normal file
21
server/internal/models/supplement.go
Normal file
@@ -0,0 +1,21 @@
|
||||
package models
|
||||
|
||||
func (Supplement) TableName() string {
|
||||
return "supplements"
|
||||
}
|
||||
|
||||
type Supplement struct {
|
||||
BaseModel
|
||||
Name string `json:"name" db:"name"`
|
||||
Description string `json:"description" db:"description"`
|
||||
Price int `json:"price" db:"price"`
|
||||
Image string `json:"image" db:"image"`
|
||||
Nutrients []Nutrient `json:"nutrients" db:"nutrients" gorm:"many2many:supplement_nutrients;"`
|
||||
SupplementNutrients []SupplementNutrient `json:"supplementNutrients" db:"supplementNutrients" gorm:"foreignKey:SupplementID"`
|
||||
}
|
||||
|
||||
type DailySupplementsOverview struct {
|
||||
Supplements []*Supplement `json:"supplements"`
|
||||
Nutrients []*Nutrient `json:"nutrients"`
|
||||
Overview []*SupplementNutrientOverview `json:"overview"`
|
||||
}
|
||||
22
server/internal/models/supplement_nutrient.go
Normal file
22
server/internal/models/supplement_nutrient.go
Normal file
@@ -0,0 +1,22 @@
|
||||
package models
|
||||
|
||||
func (SupplementNutrient) TableName() string {
|
||||
return "supplement_nutrients"
|
||||
}
|
||||
|
||||
type SupplementNutrient struct {
|
||||
BaseModel
|
||||
SupplementID ULID `json:"supplementId" db:"supplement_id"`
|
||||
NutrientID ULID `json:"nutrientId" db:"nutrient_id"`
|
||||
ServingSize string `json:"servingSize" db:"serving_size"`
|
||||
PerServing string `json:"perServing" db:"per_serving"`
|
||||
PerServingReferenceIntake string `json:"perServingReferenceIntake" db:"per_serving_reference_intake"`
|
||||
}
|
||||
|
||||
type SupplementNutrientOverview struct {
|
||||
SupplementID ULID `json:"supplementId" db:"supplement_id"`
|
||||
NutrientID ULID `json:"nutrientId" db:"nutrient_id"`
|
||||
ServingSize string `json:"servingSize" db:"serving_size"`
|
||||
PerServing string `json:"perServing" db:"per_serving"`
|
||||
PerServingReferenceIntake string `json:"perServingReferenceIntake" db:"per_serving_reference_intake"`
|
||||
}
|
||||
56
server/internal/models/todo.go
Normal file
56
server/internal/models/todo.go
Normal file
@@ -0,0 +1,56 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
func (Todo) TableName() string {
|
||||
return "todos"
|
||||
}
|
||||
|
||||
type Todo struct {
|
||||
BaseModel
|
||||
UserID ULID `json:"userId" db:"user_id" gorm:"not null"`
|
||||
Title string `json:"title" db:"title" gorm:"not null"`
|
||||
Description string `json:"description" db:"description"`
|
||||
Color string `json:"color" db:"color" gorm:"default:'#3B82F6'"` // Default blue color
|
||||
IsActive bool `json:"isActive" db:"is_active" gorm:"default:true"`
|
||||
|
||||
// Relationships
|
||||
User User `json:"user" gorm:"foreignKey:UserID"`
|
||||
Completions []TodoCompletion `json:"completions" gorm:"foreignKey:TodoID"`
|
||||
}
|
||||
|
||||
func (TodoCompletion) TableName() string {
|
||||
return "todo_completions"
|
||||
}
|
||||
|
||||
type TodoCompletion struct {
|
||||
BaseModel
|
||||
TodoID ULID `json:"todoId" db:"todo_id" gorm:"not null"`
|
||||
UserID ULID `json:"userId" db:"user_id" gorm:"not null"`
|
||||
CompletedAt time.Time `json:"completedAt" db:"completed_at" gorm:"not null"`
|
||||
Description string `json:"description" db:"description"` // User's notes about how they completed it
|
||||
|
||||
// Relationships
|
||||
Todo Todo `json:"todo" gorm:"foreignKey:TodoID"`
|
||||
User User `json:"user" gorm:"foreignKey:UserID"`
|
||||
}
|
||||
|
||||
// TodoWithStats represents a todo with completion statistics
|
||||
type TodoWithStats struct {
|
||||
Todo
|
||||
CurrentStreak int `json:"currentStreak"`
|
||||
LongestStreak int `json:"longestStreak"`
|
||||
TotalCompletions int `json:"totalCompletions"`
|
||||
LastCompletedAt *time.Time `json:"lastCompletedAt"`
|
||||
CompletedToday bool `json:"completedToday"`
|
||||
}
|
||||
|
||||
// DailyTodoSummary represents todos for a specific day
|
||||
type DailyTodoSummary struct {
|
||||
Date time.Time `json:"date"`
|
||||
Todos []TodoWithStats `json:"todos"`
|
||||
CompletedCount int `json:"completedCount"`
|
||||
TotalCount int `json:"totalCount"`
|
||||
}
|
||||
141
server/internal/models/ulid.go
Normal file
141
server/internal/models/ulid.go
Normal file
@@ -0,0 +1,141 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"database/sql/driver"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/oklog/ulid/v2"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/schema"
|
||||
)
|
||||
|
||||
// ULID is a custom type that wraps oklog/ulid/v2.ULID and implements
|
||||
// GORM's Scanner and Valuer interfaces for database operations
|
||||
type ULID struct {
|
||||
ulid.ULID
|
||||
}
|
||||
|
||||
// NewULID creates a new ULID from a ulid.ULID
|
||||
func NewULID(u ulid.ULID) ULID {
|
||||
return ULID{ULID: u}
|
||||
}
|
||||
|
||||
// ParseULID parses a string into a ULID
|
||||
func ParseULID(s string) (ULID, error) {
|
||||
u, err := ulid.Parse(s)
|
||||
if err != nil {
|
||||
return ULID{}, err
|
||||
}
|
||||
return ULID{ULID: u}, nil
|
||||
}
|
||||
|
||||
// MustParseULID parses a string into a ULID and panics on error
|
||||
func MustParseULID(s string) ULID {
|
||||
u, err := ParseULID(s)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
// GenerateULID creates a new ULID with the current timestamp
|
||||
func GenerateULID() ULID {
|
||||
return ULID{ULID: ulid.MustNew(ulid.Timestamp(time.Now()), rand.Reader)}
|
||||
}
|
||||
|
||||
// GenerateULIDWithTime creates a new ULID with the specified timestamp
|
||||
func GenerateULIDWithTime(t time.Time) ULID {
|
||||
return ULID{ULID: ulid.MustNew(ulid.Timestamp(t), rand.Reader)}
|
||||
}
|
||||
|
||||
// Scan implements the sql.Scanner interface for reading from database
|
||||
func (u *ULID) Scan(value interface{}) error {
|
||||
if value == nil {
|
||||
*u = ULID{}
|
||||
return nil
|
||||
}
|
||||
|
||||
switch v := value.(type) {
|
||||
case string:
|
||||
parsed, err := ulid.Parse(v)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot parse ULID from string: %w", err)
|
||||
}
|
||||
u.ULID = parsed
|
||||
return nil
|
||||
case []byte:
|
||||
parsed, err := ulid.Parse(string(v))
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot parse ULID from bytes: %w", err)
|
||||
}
|
||||
u.ULID = parsed
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("cannot scan %T into ULID", value)
|
||||
}
|
||||
}
|
||||
|
||||
// Value implements the driver.Valuer interface for writing to database
|
||||
func (u ULID) Value() (driver.Value, error) {
|
||||
if u.ULID == (ulid.ULID{}) {
|
||||
return nil, nil
|
||||
}
|
||||
return u.ULID.String(), nil
|
||||
}
|
||||
|
||||
// GormDataType returns the data type for GORM
|
||||
func (ULID) GormDataType() string {
|
||||
return "char(26)"
|
||||
}
|
||||
|
||||
// GormDBDataType returns the database-specific data type for GORM
|
||||
func (ULID) GormDBDataType(db *gorm.DB, field *schema.Field) string {
|
||||
switch db.Dialector.Name() {
|
||||
case "postgres":
|
||||
return "char(26)"
|
||||
case "mysql":
|
||||
return "char(26)"
|
||||
case "sqlite":
|
||||
return "text"
|
||||
default:
|
||||
return "char(26)"
|
||||
}
|
||||
}
|
||||
|
||||
// MarshalJSON implements json.Marshaler
|
||||
func (u ULID) MarshalJSON() ([]byte, error) {
|
||||
return []byte(`"` + u.ULID.String() + `"`), nil
|
||||
}
|
||||
|
||||
// UnmarshalJSON implements json.Unmarshaler
|
||||
func (u *ULID) UnmarshalJSON(data []byte) error {
|
||||
if len(data) < 2 || data[0] != '"' || data[len(data)-1] != '"' {
|
||||
return fmt.Errorf("invalid JSON string for ULID")
|
||||
}
|
||||
|
||||
str := string(data[1 : len(data)-1])
|
||||
if str == "" {
|
||||
*u = ULID{}
|
||||
return nil
|
||||
}
|
||||
|
||||
parsed, err := ulid.Parse(str)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot parse ULID from JSON: %w", err)
|
||||
}
|
||||
|
||||
u.ULID = parsed
|
||||
return nil
|
||||
}
|
||||
|
||||
// String returns the string representation of the ULID
|
||||
func (u ULID) String() string {
|
||||
return u.ULID.String()
|
||||
}
|
||||
|
||||
// IsZero returns true if the ULID is zero value
|
||||
func (u ULID) IsZero() bool {
|
||||
return u.ULID == ulid.ULID{}
|
||||
}
|
||||
25
server/internal/models/user.go
Executable file
25
server/internal/models/user.go
Executable file
@@ -0,0 +1,25 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
type PlatformRole string
|
||||
|
||||
const (
|
||||
PlatformRoleUser PlatformRole = "USER"
|
||||
PlatformRoleAdmin PlatformRole = "ADMIN"
|
||||
)
|
||||
|
||||
func (User) TableName() string {
|
||||
return "users"
|
||||
}
|
||||
|
||||
type User struct {
|
||||
ID []byte `gorm:"primaryKey;type:bytea" json:"id" db:"id"`
|
||||
CreatedAt time.Time `json:"createdAt" db:"created_at"`
|
||||
UpdatedAt time.Time `json:"updatedAt" db:"updated_at"`
|
||||
FirstName string `json:"firstName" db:"first_name"`
|
||||
LastName string `json:"lastName" db:"last_name"`
|
||||
Email string `gorm:"uniqueIndex;not null" json:"email" db:"email"`
|
||||
Password string `gorm:"not null" json:"password" db:"password"`
|
||||
PlatformRole PlatformRole `gorm:"not null" json:"platformRole" db:"platform_role"`
|
||||
}
|
||||
81
server/internal/queue/email_service.go
Normal file
81
server/internal/queue/email_service.go
Normal file
@@ -0,0 +1,81 @@
|
||||
// internal/queue/email_service.go
|
||||
package queue
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"gopkg.in/gomail.v2"
|
||||
)
|
||||
|
||||
// EmailService defines the interface for sending emails
|
||||
type EmailService interface {
|
||||
Send(to string, subject string, body string) error
|
||||
}
|
||||
|
||||
// SMTPEmailService implements EmailService using SMTP
|
||||
type SMTPEmailService struct {
|
||||
dialer *gomail.Dialer
|
||||
from string
|
||||
}
|
||||
|
||||
type SMTPConfig struct {
|
||||
Host string
|
||||
Port int
|
||||
Username string
|
||||
Password string
|
||||
From string
|
||||
}
|
||||
|
||||
func NewSMTPEmailService(config SMTPConfig) *SMTPEmailService {
|
||||
dialer := gomail.NewDialer(
|
||||
config.Host,
|
||||
config.Port,
|
||||
config.Username,
|
||||
config.Password,
|
||||
)
|
||||
|
||||
return &SMTPEmailService{
|
||||
dialer: dialer,
|
||||
from: config.From,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *SMTPEmailService) Send(to string, subject string, body string) error {
|
||||
m := gomail.NewMessage()
|
||||
m.SetHeader("From", s.from)
|
||||
m.SetHeader("To", to)
|
||||
m.SetHeader("Subject", subject)
|
||||
m.SetBody("text/html", body)
|
||||
|
||||
if err := s.dialer.DialAndSend(m); err != nil {
|
||||
return fmt.Errorf("failed to send email: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// MockEmailService implements EmailService for testing
|
||||
type MockEmailService struct {
|
||||
SentEmails []MockEmail
|
||||
}
|
||||
|
||||
type MockEmail struct {
|
||||
To string
|
||||
Subject string
|
||||
Body string
|
||||
}
|
||||
|
||||
func NewMockEmailService() *MockEmailService {
|
||||
return &MockEmailService{
|
||||
SentEmails: make([]MockEmail, 0),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *MockEmailService) Send(to string, subject string, body string) error {
|
||||
s.SentEmails = append(s.SentEmails, MockEmail{
|
||||
To: to,
|
||||
Subject: subject,
|
||||
Body: body,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
88
server/internal/queue/worker.go
Normal file
88
server/internal/queue/worker.go
Normal file
@@ -0,0 +1,88 @@
|
||||
// internal/queue/worker.go
|
||||
package queue
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/hibiken/asynq"
|
||||
)
|
||||
|
||||
const (
|
||||
TypeEmailDelivery = "email:deliver"
|
||||
TypeDataExport = "data:export"
|
||||
)
|
||||
|
||||
type TaskHandler struct {
|
||||
emailService EmailService
|
||||
// Add other services needed for tasks
|
||||
}
|
||||
|
||||
func NewTaskHandler(emailService EmailService) *TaskHandler {
|
||||
return &TaskHandler{
|
||||
emailService: emailService,
|
||||
}
|
||||
}
|
||||
|
||||
func NewQueueClient(redisAddr string) *asynq.Client {
|
||||
return asynq.NewClient(asynq.RedisClientOpt{Addr: redisAddr})
|
||||
}
|
||||
|
||||
func StartWorkerServer(redisAddr string, handler *TaskHandler) {
|
||||
srv := asynq.NewServer(
|
||||
asynq.RedisClientOpt{Addr: redisAddr},
|
||||
asynq.Config{
|
||||
Concurrency: 10,
|
||||
Queues: map[string]int{
|
||||
"critical": 6,
|
||||
"default": 3,
|
||||
"low": 1,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
mux := asynq.NewServeMux()
|
||||
mux.HandleFunc(TypeEmailDelivery, handler.HandleEmailDeliveryTask)
|
||||
mux.HandleFunc(TypeDataExport, handler.HandleDataExportTask)
|
||||
|
||||
if err := srv.Run(mux); err != nil {
|
||||
log.Fatalf("Could not run queue server: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Task Handlers
|
||||
func (h *TaskHandler) HandleEmailDeliveryTask(ctx context.Context, t *asynq.Task) error {
|
||||
var p EmailDeliveryPayload
|
||||
if err := json.Unmarshal(t.Payload(), &p); err != nil {
|
||||
return fmt.Errorf("json.Unmarshal failed: %v", err)
|
||||
}
|
||||
|
||||
// Process the email delivery task
|
||||
return h.emailService.Send(p.To, p.Subject, p.Body)
|
||||
}
|
||||
|
||||
func (h *TaskHandler) HandleDataExportTask(ctx context.Context, t *asynq.Task) error {
|
||||
var p DataExportPayload
|
||||
if err := json.Unmarshal(t.Payload(), &p); err != nil {
|
||||
return fmt.Errorf("json.Unmarshal failed: %v", err)
|
||||
}
|
||||
|
||||
// Process the data export task
|
||||
return nil
|
||||
}
|
||||
|
||||
// Task Payloads
|
||||
type EmailDeliveryPayload struct {
|
||||
To string
|
||||
Subject string
|
||||
Body string
|
||||
}
|
||||
|
||||
type DataExportPayload struct {
|
||||
UserID string
|
||||
Format string
|
||||
Timestamp time.Time
|
||||
}
|
||||
22
server/internal/repository/category_repository.go
Normal file
22
server/internal/repository/category_repository.go
Normal file
@@ -0,0 +1,22 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"go-server/internal/models"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type CategoryRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewCategoryRepository(db *gorm.DB) *CategoryRepository {
|
||||
return &CategoryRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *CategoryRepository) GetAll(ctx context.Context) ([]models.Category, error) {
|
||||
var categories []models.Category
|
||||
err := r.db.WithContext(ctx).Find(&categories).Error
|
||||
return categories, err
|
||||
}
|
||||
187
server/internal/repository/daily_overview_repository.go
Normal file
187
server/internal/repository/daily_overview_repository.go
Normal file
@@ -0,0 +1,187 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"go-server/internal/models"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type DailyOverviewRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewDailyOverviewRepository(db *gorm.DB) *DailyOverviewRepository {
|
||||
return &DailyOverviewRepository{db: db}
|
||||
}
|
||||
|
||||
// NutrientTotal represents the aggregated total for a nutrient across all supplements
|
||||
type NutrientTotal struct {
|
||||
NutrientID models.ULID `json:"nutrientId" db:"nutrient_id"`
|
||||
NutrientName string `json:"nutrientName" db:"nutrient_name"`
|
||||
Description string `json:"description" db:"description"`
|
||||
TotalAmount string `json:"totalAmount" db:"total_amount"`
|
||||
Unit string `json:"unit" db:"unit"`
|
||||
Categories []string `json:"categories" db:"categories"`
|
||||
}
|
||||
|
||||
// SupplementNutrientDetails represents detailed breakdown by supplement
|
||||
type SupplementNutrientDetails struct {
|
||||
SupplementID models.ULID `json:"supplementId" db:"supplement_id"`
|
||||
SupplementName string `json:"supplementName" db:"supplement_name"`
|
||||
NutrientID models.ULID `json:"nutrientId" db:"nutrient_id"`
|
||||
NutrientName string `json:"nutrientName" db:"nutrient_name"`
|
||||
Amount string `json:"amount" db:"amount"`
|
||||
Unit string `json:"unit" db:"unit"`
|
||||
ServingSize string `json:"servingSize" db:"serving_size"`
|
||||
ReferenceIntake string `json:"referenceIntake" db:"reference_intake"`
|
||||
}
|
||||
|
||||
// GetNutrientTotals returns aggregated nutrient totals across all supplements
|
||||
// This is perfect for a daily overview where you want to see total intake
|
||||
func (r *DailyOverviewRepository) GetNutrientTotals(ctx context.Context) ([]NutrientTotal, error) {
|
||||
var results []NutrientTotal
|
||||
|
||||
query := `
|
||||
SELECT
|
||||
n.id as nutrient_id,
|
||||
n.name as nutrient_name,
|
||||
n.description,
|
||||
sn.per_serving_reference_intake,
|
||||
-- For now, we'll concatenate amounts (later we can parse and sum numeric values)
|
||||
STRING_AGG(sn.per_serving, ' + ') as total_amount,
|
||||
-- Extract unit from first entry (assumption: same nutrient has same unit)
|
||||
SPLIT_PART(MIN(sn.per_serving), ' ', 2) as unit,
|
||||
-- Get all categories for this nutrient
|
||||
ARRAY_AGG(DISTINCT c.name) as categories
|
||||
FROM nutrients n
|
||||
LEFT JOIN supplement_nutrients sn ON n.id = sn.nutrient_id
|
||||
LEFT JOIN supplements s ON sn.supplement_id = s.id
|
||||
LEFT JOIN nutrient_categories nc ON n.id = nc.nutrient_id
|
||||
LEFT JOIN categories c ON nc.category_id = c.id
|
||||
WHERE sn.id IS NOT NULL -- Only nutrients that are in supplements
|
||||
GROUP BY n.id, n.name, n.description, sn.per_serving_reference_intake
|
||||
ORDER BY n.name
|
||||
`
|
||||
|
||||
err := r.db.WithContext(ctx).Raw(query).Scan(&results).Error
|
||||
return results, err
|
||||
}
|
||||
|
||||
// GetSupplementBreakdown returns detailed breakdown of nutrients by supplement
|
||||
func (r *DailyOverviewRepository) GetSupplementBreakdown(ctx context.Context) ([]SupplementNutrientDetails, error) {
|
||||
var results []SupplementNutrientDetails
|
||||
|
||||
query := `
|
||||
SELECT
|
||||
s.id as supplement_id,
|
||||
s.name as supplement_name,
|
||||
n.id as nutrient_id,
|
||||
n.name as nutrient_name,
|
||||
sn.per_serving as amount,
|
||||
SPLIT_PART(sn.per_serving, ' ', 2) as unit,
|
||||
sn.serving_size,
|
||||
sn.per_serving_reference_intake as reference_intake
|
||||
FROM supplements s
|
||||
JOIN supplement_nutrients sn ON s.id = sn.supplement_id
|
||||
JOIN nutrients n ON sn.nutrient_id = n.id
|
||||
ORDER BY s.name, n.name
|
||||
`
|
||||
|
||||
err := r.db.WithContext(ctx).Raw(query).Scan(&results).Error
|
||||
return results, err
|
||||
}
|
||||
|
||||
// GetNutrientsByCategory returns nutrients grouped by category with totals
|
||||
func (r *DailyOverviewRepository) GetNutrientsByCategory(ctx context.Context) (map[string][]NutrientTotal, error) {
|
||||
var results []struct {
|
||||
CategoryName string `db:"category_name"`
|
||||
CategoryID models.ULID `db:"category_id"`
|
||||
NutrientID models.ULID `db:"nutrient_id"`
|
||||
NutrientName string `db:"nutrient_name"`
|
||||
Description string `db:"description"`
|
||||
TotalAmount string `db:"total_amount"`
|
||||
Unit string `db:"unit"`
|
||||
}
|
||||
|
||||
query := `
|
||||
SELECT
|
||||
c.name as category_name,
|
||||
c.id as category_id,
|
||||
n.id as nutrient_id,
|
||||
n.name as nutrient_name,
|
||||
n.description,
|
||||
STRING_AGG(sn.per_serving, ' + ') as total_amount,
|
||||
SPLIT_PART(MIN(sn.per_serving), ' ', 2) as unit
|
||||
FROM categories c
|
||||
JOIN nutrient_categories nc ON c.id = nc.category_id
|
||||
JOIN nutrients n ON nc.nutrient_id = n.id
|
||||
LEFT JOIN supplement_nutrients sn ON n.id = sn.nutrient_id
|
||||
WHERE sn.id IS NOT NULL
|
||||
GROUP BY c.name, n.id, n.name, n.description, c.id
|
||||
ORDER BY c.name, n.name
|
||||
`
|
||||
|
||||
err := r.db.WithContext(ctx).Raw(query).Scan(&results).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Group by category
|
||||
categoryMap := make(map[string][]NutrientTotal)
|
||||
for _, result := range results {
|
||||
nutrient := NutrientTotal{
|
||||
NutrientID: result.NutrientID,
|
||||
NutrientName: result.NutrientName,
|
||||
Description: result.Description,
|
||||
TotalAmount: result.TotalAmount,
|
||||
Unit: result.Unit,
|
||||
Categories: []string{result.CategoryID.String()},
|
||||
}
|
||||
categoryMap[result.CategoryName] = append(categoryMap[result.CategoryName], nutrient)
|
||||
}
|
||||
|
||||
return categoryMap, nil
|
||||
}
|
||||
|
||||
// ExecuteRawQuery allows executing arbitrary SQL queries and scanning into any struct
|
||||
// This gives you full flexibility for custom queries
|
||||
func (r *DailyOverviewRepository) ExecuteRawQuery(ctx context.Context, query string, dest interface{}, args ...interface{}) error {
|
||||
return r.db.WithContext(ctx).Raw(query, args...).Scan(dest).Error
|
||||
}
|
||||
|
||||
// ExecuteRawQueryWithResult executes a raw query and returns the result as a map
|
||||
// Useful for dynamic queries where you don't know the structure ahead of time
|
||||
func (r *DailyOverviewRepository) ExecuteRawQueryWithResult(ctx context.Context, query string, args ...interface{}) ([]map[string]interface{}, error) {
|
||||
rows, err := r.db.WithContext(ctx).Raw(query, args...).Rows()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
columns, err := rows.Columns()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var results []map[string]interface{}
|
||||
for rows.Next() {
|
||||
values := make([]interface{}, len(columns))
|
||||
valuePtrs := make([]interface{}, len(columns))
|
||||
for i := range columns {
|
||||
valuePtrs[i] = &values[i]
|
||||
}
|
||||
|
||||
if err := rows.Scan(valuePtrs...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := make(map[string]interface{})
|
||||
for i, col := range columns {
|
||||
result[col] = values[i]
|
||||
}
|
||||
results = append(results, result)
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
28
server/internal/repository/nutrient_repository.go
Normal file
28
server/internal/repository/nutrient_repository.go
Normal file
@@ -0,0 +1,28 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"go-server/internal/models"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type NutrientRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewNutrientRepository(db *gorm.DB) *NutrientRepository {
|
||||
return &NutrientRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *NutrientRepository) GetAll(ctx context.Context) ([]*models.Nutrient, error) {
|
||||
var nutrients []*models.Nutrient
|
||||
err := r.db.WithContext(ctx).
|
||||
Preload("Categories").
|
||||
Find(&nutrients).
|
||||
Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return nutrients, nil
|
||||
}
|
||||
57
server/internal/repository/supplement_repository.go
Normal file
57
server/internal/repository/supplement_repository.go
Normal file
@@ -0,0 +1,57 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"go-server/internal/models"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type SupplementRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewSupplementRepository(db *gorm.DB) *SupplementRepository {
|
||||
return &SupplementRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *SupplementRepository) GetAll(ctx context.Context) ([]*models.Supplement, error) {
|
||||
var supplements []*models.Supplement
|
||||
err := r.db.WithContext(ctx).
|
||||
Preload("Nutrients").
|
||||
Preload("SupplementNutrients").
|
||||
Find(&supplements).
|
||||
Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return supplements, nil
|
||||
}
|
||||
|
||||
func (r *SupplementRepository) GetById(ctx context.Context, id string) (*models.Supplement, error) {
|
||||
var supplement *models.Supplement
|
||||
err := r.db.WithContext(ctx).
|
||||
Preload("Nutrients").
|
||||
Preload("SupplementNutrients").
|
||||
First(&supplement, "id = ?", id).
|
||||
Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return supplement, nil
|
||||
}
|
||||
|
||||
func (r *SupplementRepository) GetDailySupplementsOverview(ctx context.Context) ([]*models.SupplementNutrientOverview, error) {
|
||||
var supplementNutrientOverview []*models.SupplementNutrientOverview
|
||||
err := r.db.WithContext(ctx).
|
||||
Table("supplements").
|
||||
Select("supplements.id as supplement_id, supplements.name as supplement_name, supplements.description as supplement_description, supplement_nutrients.serving_size as serving_size, supplement_nutrients.per_serving as per_serving, supplement_nutrients.per_serving_reference_intake as per_serving_reference_intake, nutrients.id as nutrient_id, nutrients.name as nutrient_name, nutrients.description as nutrient_description").
|
||||
Joins("INNER JOIN supplement_nutrients ON supplements.id = supplement_nutrients.supplement_id").
|
||||
Joins("JOIN nutrients ON supplement_nutrients.nutrient_id = nutrients.id").
|
||||
Find(&supplementNutrientOverview).
|
||||
Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return supplementNutrientOverview, nil
|
||||
}
|
||||
247
server/internal/repository/todo_repository.go
Normal file
247
server/internal/repository/todo_repository.go
Normal file
@@ -0,0 +1,247 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"go-server/internal/models"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type TodoRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewTodoRepository(db *gorm.DB) *TodoRepository {
|
||||
return &TodoRepository{db: db}
|
||||
}
|
||||
|
||||
// CreateTodo creates a new todo for a user
|
||||
func (r *TodoRepository) CreateTodo(ctx context.Context, todo *models.Todo) error {
|
||||
return r.db.WithContext(ctx).Create(todo).Error
|
||||
}
|
||||
|
||||
// GetTodosByUserID gets all active todos for a user
|
||||
func (r *TodoRepository) GetTodosByUserID(ctx context.Context, userID models.ULID) ([]models.Todo, error) {
|
||||
var todos []models.Todo
|
||||
err := r.db.WithContext(ctx).
|
||||
Where("user_id = ? AND is_active = ?", userID, true).
|
||||
Order("created_at ASC").
|
||||
Find(&todos).Error
|
||||
return todos, err
|
||||
}
|
||||
|
||||
// GetTodoByID gets a todo by ID and user ID (for security)
|
||||
func (r *TodoRepository) GetTodoByID(ctx context.Context, todoID, userID models.ULID) (*models.Todo, error) {
|
||||
var todo models.Todo
|
||||
err := r.db.WithContext(ctx).
|
||||
Where("id = ? AND user_id = ?", todoID, userID).
|
||||
First(&todo).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &todo, nil
|
||||
}
|
||||
|
||||
// UpdateTodo updates a todo
|
||||
func (r *TodoRepository) UpdateTodo(ctx context.Context, todo *models.Todo) error {
|
||||
return r.db.WithContext(ctx).Save(todo).Error
|
||||
}
|
||||
|
||||
// DeleteTodo soft deletes a todo (sets is_active to false)
|
||||
func (r *TodoRepository) DeleteTodo(ctx context.Context, todoID, userID models.ULID) error {
|
||||
return r.db.WithContext(ctx).
|
||||
Model(&models.Todo{}).
|
||||
Where("id = ? AND user_id = ?", todoID, userID).
|
||||
Update("is_active", false).Error
|
||||
}
|
||||
|
||||
// CompleteTodo creates a completion record for a todo
|
||||
func (r *TodoRepository) CompleteTodo(ctx context.Context, completion *models.TodoCompletion) error {
|
||||
return r.db.WithContext(ctx).Create(completion).Error
|
||||
}
|
||||
|
||||
// GetTodayCompletions gets all completions for today for a user
|
||||
func (r *TodoRepository) GetTodayCompletions(ctx context.Context, userID models.ULID) ([]models.TodoCompletion, error) {
|
||||
now := time.Now()
|
||||
startOfDay := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
||||
endOfDay := startOfDay.Add(24 * time.Hour)
|
||||
|
||||
var completions []models.TodoCompletion
|
||||
err := r.db.WithContext(ctx).
|
||||
Preload("Todo").
|
||||
Where("user_id = ? AND completed_at >= ? AND completed_at < ?", userID, startOfDay, endOfDay).
|
||||
Order("completed_at DESC").
|
||||
Find(&completions).Error
|
||||
return completions, err
|
||||
}
|
||||
|
||||
// GetCompletionsByDateRange gets completions for a date range
|
||||
func (r *TodoRepository) GetCompletionsByDateRange(ctx context.Context, userID models.ULID, startDate, endDate time.Time) ([]models.TodoCompletion, error) {
|
||||
var completions []models.TodoCompletion
|
||||
err := r.db.WithContext(ctx).
|
||||
Preload("Todo").
|
||||
Where("user_id = ? AND completed_at >= ? AND completed_at < ?", userID, startDate, endDate).
|
||||
Order("completed_at DESC").
|
||||
Find(&completions).Error
|
||||
return completions, err
|
||||
}
|
||||
|
||||
// GetTodoWithStats gets todos with completion statistics using raw SQL for better performance
|
||||
func (r *TodoRepository) GetTodosWithStats(ctx context.Context, userID models.ULID) ([]models.TodoWithStats, error) {
|
||||
var results []models.TodoWithStats
|
||||
|
||||
query := `
|
||||
WITH todo_stats AS (
|
||||
SELECT
|
||||
t.id,
|
||||
t.user_id,
|
||||
t.title,
|
||||
t.description,
|
||||
t.color,
|
||||
t.is_active,
|
||||
t.created_at,
|
||||
t.updated_at,
|
||||
COUNT(tc.id) as total_completions,
|
||||
MAX(tc.completed_at) as last_completed_at,
|
||||
CASE
|
||||
WHEN MAX(tc.completed_at) >= CURRENT_DATE
|
||||
THEN true
|
||||
ELSE false
|
||||
END as completed_today
|
||||
FROM todos t
|
||||
LEFT JOIN todo_completions tc ON t.id = tc.todo_id
|
||||
WHERE t.user_id = ? AND t.is_active = true
|
||||
GROUP BY t.id, t.user_id, t.title, t.description, t.color, t.is_active, t.created_at, t.updated_at
|
||||
),
|
||||
streak_calc AS (
|
||||
SELECT
|
||||
ts.*,
|
||||
COALESCE(
|
||||
(SELECT COUNT(*)
|
||||
FROM generate_series(
|
||||
CURRENT_DATE - INTERVAL '365 days',
|
||||
CURRENT_DATE,
|
||||
INTERVAL '1 day'
|
||||
) AS date_series(date)
|
||||
WHERE EXISTS (
|
||||
SELECT 1 FROM todo_completions tc2
|
||||
WHERE tc2.todo_id = ts.id
|
||||
AND DATE(tc2.completed_at) = date_series.date
|
||||
)
|
||||
AND date_series.date <= CURRENT_DATE
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM generate_series(
|
||||
date_series.date + INTERVAL '1 day',
|
||||
CURRENT_DATE,
|
||||
INTERVAL '1 day'
|
||||
) AS gap_check(gap_date)
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM todo_completions tc3
|
||||
WHERE tc3.todo_id = ts.id
|
||||
AND DATE(tc3.completed_at) = gap_check.gap_date
|
||||
)
|
||||
)
|
||||
), 0
|
||||
) as current_streak
|
||||
FROM todo_stats ts
|
||||
)
|
||||
SELECT
|
||||
sc.*,
|
||||
COALESCE(
|
||||
(SELECT MAX(streak_length)
|
||||
FROM (
|
||||
SELECT COUNT(*) as streak_length
|
||||
FROM (
|
||||
SELECT
|
||||
DATE(tc.completed_at) as completion_date,
|
||||
ROW_NUMBER() OVER (ORDER BY DATE(tc.completed_at)) as rn,
|
||||
DATE(tc.completed_at) - INTERVAL '1 day' * ROW_NUMBER() OVER (ORDER BY DATE(tc.completed_at)) as streak_group
|
||||
FROM todo_completions tc
|
||||
WHERE tc.todo_id = sc.id
|
||||
) grouped
|
||||
GROUP BY streak_group
|
||||
) streaks), 0
|
||||
) as longest_streak
|
||||
FROM streak_calc sc
|
||||
ORDER BY sc.created_at ASC
|
||||
`
|
||||
|
||||
rows, err := r.db.WithContext(ctx).Raw(query, userID).Rows()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var result models.TodoWithStats
|
||||
var lastCompletedAt *time.Time
|
||||
|
||||
err := rows.Scan(
|
||||
&result.ID,
|
||||
&result.UserID,
|
||||
&result.Title,
|
||||
&result.Description,
|
||||
&result.Color,
|
||||
&result.IsActive,
|
||||
&result.CreatedAt,
|
||||
&result.UpdatedAt,
|
||||
&result.TotalCompletions,
|
||||
&lastCompletedAt,
|
||||
&result.CompletedToday,
|
||||
&result.CurrentStreak,
|
||||
&result.LongestStreak,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result.LastCompletedAt = lastCompletedAt
|
||||
results = append(results, result)
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// CheckTodoCompletedToday checks if a todo was completed today
|
||||
func (r *TodoRepository) CheckTodoCompletedToday(ctx context.Context, todoID, userID models.ULID) (bool, error) {
|
||||
now := time.Now()
|
||||
startOfDay := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
||||
endOfDay := startOfDay.Add(24 * time.Hour)
|
||||
|
||||
var count int64
|
||||
err := r.db.WithContext(ctx).
|
||||
Model(&models.TodoCompletion{}).
|
||||
Where("todo_id = ? AND user_id = ? AND completed_at >= ? AND completed_at < ?",
|
||||
todoID, userID, startOfDay, endOfDay).
|
||||
Count(&count).Error
|
||||
|
||||
return count > 0, err
|
||||
}
|
||||
|
||||
// GetActivityLog gets activity log with pagination
|
||||
func (r *TodoRepository) GetActivityLog(ctx context.Context, userID models.ULID, limit, offset int) ([]models.TodoCompletion, error) {
|
||||
var completions []models.TodoCompletion
|
||||
err := r.db.WithContext(ctx).
|
||||
Preload("Todo").
|
||||
Where("user_id = ?", userID).
|
||||
Order("completed_at DESC").
|
||||
Limit(limit).
|
||||
Offset(offset).
|
||||
Find(&completions).Error
|
||||
return completions, err
|
||||
}
|
||||
|
||||
// GetActivityLogByDate gets activity log for a specific date
|
||||
func (r *TodoRepository) GetActivityLogByDate(ctx context.Context, userID models.ULID, date time.Time) ([]models.TodoCompletion, error) {
|
||||
startOfDay := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location())
|
||||
endOfDay := startOfDay.Add(24 * time.Hour)
|
||||
|
||||
var completions []models.TodoCompletion
|
||||
err := r.db.WithContext(ctx).
|
||||
Preload("Todo").
|
||||
Where("user_id = ? AND completed_at >= ? AND completed_at < ?", userID, startOfDay, endOfDay).
|
||||
Order("completed_at DESC").
|
||||
Find(&completions).Error
|
||||
return completions, err
|
||||
}
|
||||
51
server/internal/repository/user_repository.go
Normal file
51
server/internal/repository/user_repository.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"go-server/internal/models"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type UserRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewUserRepository(db *gorm.DB) *UserRepository {
|
||||
return &UserRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *UserRepository) Create(ctx context.Context, user *models.User) error {
|
||||
return r.db.WithContext(ctx).Create(user).Error
|
||||
}
|
||||
|
||||
func (r *UserRepository) GetByID(ctx context.Context, id uuid.UUID) (*models.User, error) {
|
||||
var user models.User
|
||||
if err := r.db.WithContext(ctx).First(&user, id).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
func (r *UserRepository) Update(ctx context.Context, user *models.User) error {
|
||||
return r.db.WithContext(ctx).Save(user).Error
|
||||
}
|
||||
|
||||
func (r *UserRepository) Delete(ctx context.Context, id uuid.UUID) error {
|
||||
return r.db.WithContext(ctx).Delete(&models.User{}, id).Error
|
||||
}
|
||||
|
||||
func (r *UserRepository) GetAll(ctx context.Context) ([]*models.User, error) {
|
||||
var users []*models.User
|
||||
err := r.db.WithContext(ctx).Find(&users).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return users, nil
|
||||
}
|
||||
143
server/internal/routes/routes.go
Normal file
143
server/internal/routes/routes.go
Normal file
@@ -0,0 +1,143 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
handlers "go-server/internal/handlers"
|
||||
"go-server/internal/middleware"
|
||||
"go-server/internal/repository"
|
||||
"go-server/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Route defines the structure for dynamic routing
|
||||
type Route struct {
|
||||
Method string
|
||||
Path string
|
||||
HandlerFunc gin.HandlerFunc
|
||||
IsAuthRequired bool
|
||||
}
|
||||
|
||||
// Controller defines the structure for a controller with routes
|
||||
type Controller struct {
|
||||
Routes []Route
|
||||
}
|
||||
|
||||
// SetupRouter dynamically sets up routes
|
||||
func SetupRouter(db *gorm.DB) *gin.Engine {
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
|
||||
r := gin.New()
|
||||
r.Use(middleware.CORS())
|
||||
r.Use(middleware.Logger())
|
||||
r.Use(gin.Recovery())
|
||||
|
||||
// Serve simple text at "/"
|
||||
r.GET("/", func(c *gin.Context) {
|
||||
c.String(http.StatusOK, "Hello")
|
||||
})
|
||||
|
||||
// Custom 404 handler
|
||||
r.NoRoute(func(c *gin.Context) {
|
||||
c.String(http.StatusNotFound, "Not Found")
|
||||
})
|
||||
|
||||
// Initialize Repositories
|
||||
userRepo := repository.NewUserRepository(db)
|
||||
nutrientRepo := repository.NewNutrientRepository(db)
|
||||
supplementRepo := repository.NewSupplementRepository(db)
|
||||
dailyOverviewRepo := repository.NewDailyOverviewRepository(db)
|
||||
categoryRepo := repository.NewCategoryRepository(db)
|
||||
todoRepo := repository.NewTodoRepository(db)
|
||||
|
||||
// Initialize Services
|
||||
userService := service.NewUserService(userRepo)
|
||||
nutrientService := service.NewNutrientService(nutrientRepo)
|
||||
supplementService := service.NewSupplementService(supplementRepo, nutrientRepo)
|
||||
dailyOverviewService := service.NewDailyOverviewService(dailyOverviewRepo)
|
||||
categoryService := service.NewCategoryService(categoryRepo)
|
||||
todoService := service.NewTodoService(todoRepo)
|
||||
|
||||
// Initialize Controllers
|
||||
adminController := handlers.NewAdminController(userService)
|
||||
nutrientController := handlers.NewNutrientController(nutrientService)
|
||||
supplementController := handlers.NewSupplementController(supplementService)
|
||||
dailyOverviewController := handlers.NewDailyOverviewController(dailyOverviewService)
|
||||
categoryController := handlers.NewCategoryController(categoryService)
|
||||
todoController := handlers.NewTodoController(todoService)
|
||||
|
||||
skipAuth := true
|
||||
|
||||
// Define controllers and their routes
|
||||
controllers := map[string]Controller{
|
||||
"admin": {
|
||||
Routes: []Route{
|
||||
{"GET", "/user/get-all", adminController.GetUsers, true},
|
||||
{"DELETE", "/user/:id", adminController.DeleteUser, true},
|
||||
{"POST", "/user/:id/change-password", adminController.ChangePassword, true},
|
||||
{"POST", "/user", adminController.CreateUser, true},
|
||||
},
|
||||
},
|
||||
"nutrient": {
|
||||
Routes: []Route{
|
||||
{"GET", "/get-all", nutrientController.GetAll, !skipAuth},
|
||||
},
|
||||
},
|
||||
"supplement": {
|
||||
Routes: []Route{
|
||||
{"GET", "/get-all", supplementController.GetAll, !skipAuth},
|
||||
{"GET", "/get-daily-supplements-overview", supplementController.GetDailySupplementsOverview, !skipAuth},
|
||||
},
|
||||
},
|
||||
"daily-overview": {
|
||||
Routes: []Route{
|
||||
{"GET", "/overview", dailyOverviewController.GetDailyOverview, !skipAuth}, // Complete overview
|
||||
{"GET", "/totals", dailyOverviewController.GetNutrientTotals, !skipAuth}, // Just nutrient totals
|
||||
{"GET", "/breakdown", dailyOverviewController.GetSupplementBreakdown, !skipAuth}, // Supplement breakdown
|
||||
{"POST", "/query", dailyOverviewController.ExecuteCustomQuery, !skipAuth}, // Custom SQL queries
|
||||
},
|
||||
},
|
||||
"category": {
|
||||
Routes: []Route{
|
||||
{"GET", "/get-all", categoryController.GetAll, !skipAuth},
|
||||
},
|
||||
},
|
||||
"todo": {
|
||||
Routes: []Route{
|
||||
{"POST", "/create", todoController.CreateTodo, !skipAuth}, // Create new todo
|
||||
{"GET", "/list", todoController.GetTodos, !skipAuth}, // Get all todos with stats
|
||||
{"GET", "/today", todoController.GetTodaysSummary, !skipAuth}, // Get today's summary
|
||||
{"PUT", "/:id", todoController.UpdateTodo, !skipAuth}, // Update todo
|
||||
{"DELETE", "/:id", todoController.DeleteTodo, !skipAuth}, // Delete todo
|
||||
{"POST", "/:id/complete", todoController.CompleteTodo, !skipAuth}, // Complete todo
|
||||
{"GET", "/activity", todoController.GetActivityLog, !skipAuth}, // Get activity log
|
||||
{"GET", "/activity/:date", todoController.GetActivityLogByDate, !skipAuth}, // Get activity for date
|
||||
{"GET", "/weekly", todoController.GetWeeklySummary, !skipAuth}, // Get weekly summary
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Register all routes dynamically
|
||||
api := r.Group("/api")
|
||||
apiPublic := api.Group("")
|
||||
apiAuth := api.Group("")
|
||||
apiAuth.Use(middleware.AuthRequired())
|
||||
|
||||
for key, controller := range controllers {
|
||||
for _, route := range controller.Routes {
|
||||
path := fmt.Sprintf("/%s%s", key, route.Path)
|
||||
fmt.Println("Route registered", route.Method, path)
|
||||
if route.IsAuthRequired {
|
||||
apiAuth.Handle(route.Method, path, route.HandlerFunc)
|
||||
} else {
|
||||
apiPublic.Handle(route.Method, path, route.HandlerFunc)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return r
|
||||
}
|
||||
37
server/internal/server/server.go
Normal file
37
server/internal/server/server.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
engine *gin.Engine
|
||||
http *http.Server
|
||||
}
|
||||
|
||||
func NewServer(engine *gin.Engine) *Server {
|
||||
return &Server{
|
||||
engine: engine,
|
||||
http: &http.Server{
|
||||
Handler: engine,
|
||||
ReadTimeout: 5 * time.Second,
|
||||
WriteTimeout: 10 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) Start(port string) error {
|
||||
s.http.Addr = fmt.Sprintf(":%s", port)
|
||||
fmt.Println("Server started on port", port)
|
||||
return s.http.ListenAndServe()
|
||||
}
|
||||
|
||||
func (s *Server) Shutdown(ctx context.Context) error {
|
||||
fmt.Println("Shutting down server...")
|
||||
return s.http.Shutdown(ctx)
|
||||
}
|
||||
19
server/internal/service/category_service.go
Normal file
19
server/internal/service/category_service.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"go-server/internal/models"
|
||||
"go-server/internal/repository"
|
||||
)
|
||||
|
||||
type CategoryService struct {
|
||||
categoryRepo *repository.CategoryRepository
|
||||
}
|
||||
|
||||
func NewCategoryService(categoryRepo *repository.CategoryRepository) *CategoryService {
|
||||
return &CategoryService{categoryRepo: categoryRepo}
|
||||
}
|
||||
|
||||
func (s *CategoryService) GetAll(ctx context.Context) ([]models.Category, error) {
|
||||
return s.categoryRepo.GetAll(ctx)
|
||||
}
|
||||
86
server/internal/service/daily_overview_service.go
Normal file
86
server/internal/service/daily_overview_service.go
Normal file
@@ -0,0 +1,86 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"go-server/internal/repository"
|
||||
)
|
||||
|
||||
type DailyOverviewService struct {
|
||||
dailyRepo *repository.DailyOverviewRepository
|
||||
}
|
||||
|
||||
func NewDailyOverviewService(dailyRepo *repository.DailyOverviewRepository) *DailyOverviewService {
|
||||
return &DailyOverviewService{
|
||||
dailyRepo: dailyRepo,
|
||||
}
|
||||
}
|
||||
|
||||
// DailyNutrientSummary represents the complete daily overview
|
||||
type DailyNutrientSummary struct {
|
||||
NutrientTotals []repository.NutrientTotal `json:"nutrientTotals"`
|
||||
ByCategory map[string][]repository.NutrientTotal `json:"byCategory"`
|
||||
SupplementBreakdown []repository.SupplementNutrientDetails `json:"supplementBreakdown"`
|
||||
Summary DailySummaryStats `json:"summary"`
|
||||
}
|
||||
|
||||
type DailySummaryStats struct {
|
||||
TotalNutrients int `json:"totalNutrients"`
|
||||
TotalSupplements int `json:"totalSupplements"`
|
||||
CategoriesCount int `json:"categoriesCount"`
|
||||
}
|
||||
|
||||
// GetDailyOverview returns a comprehensive overview of daily nutrient intake
|
||||
func (s *DailyOverviewService) GetDailyOverview(ctx context.Context) (*DailyNutrientSummary, error) {
|
||||
// Get nutrient totals
|
||||
totals, err := s.dailyRepo.GetNutrientTotals(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Get breakdown by category
|
||||
byCategory, err := s.dailyRepo.GetNutrientsByCategory(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Get supplement breakdown
|
||||
breakdown, err := s.dailyRepo.GetSupplementBreakdown(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Calculate summary stats
|
||||
supplementMap := make(map[string]bool)
|
||||
for _, item := range breakdown {
|
||||
supplementMap[item.SupplementName] = true
|
||||
}
|
||||
|
||||
summary := DailySummaryStats{
|
||||
TotalNutrients: len(totals),
|
||||
TotalSupplements: len(supplementMap),
|
||||
CategoriesCount: len(byCategory),
|
||||
}
|
||||
|
||||
return &DailyNutrientSummary{
|
||||
NutrientTotals: totals,
|
||||
ByCategory: byCategory,
|
||||
SupplementBreakdown: breakdown,
|
||||
Summary: summary,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetNutrientTotals returns just the aggregated totals
|
||||
func (s *DailyOverviewService) GetNutrientTotals(ctx context.Context) ([]repository.NutrientTotal, error) {
|
||||
return s.dailyRepo.GetNutrientTotals(ctx)
|
||||
}
|
||||
|
||||
// GetSupplementBreakdown returns detailed breakdown by supplement
|
||||
func (s *DailyOverviewService) GetSupplementBreakdown(ctx context.Context) ([]repository.SupplementNutrientDetails, error) {
|
||||
return s.dailyRepo.GetSupplementBreakdown(ctx)
|
||||
}
|
||||
|
||||
// ExecuteCustomQuery allows executing custom SQL queries
|
||||
// This gives you maximum flexibility for complex analytics
|
||||
func (s *DailyOverviewService) ExecuteCustomQuery(ctx context.Context, query string, args ...interface{}) ([]map[string]interface{}, error) {
|
||||
return s.dailyRepo.ExecuteRawQueryWithResult(ctx, query, args...)
|
||||
}
|
||||
19
server/internal/service/nutrient_service.go
Normal file
19
server/internal/service/nutrient_service.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"go-server/internal/models"
|
||||
"go-server/internal/repository"
|
||||
)
|
||||
|
||||
type NutrientService struct {
|
||||
repo *repository.NutrientRepository
|
||||
}
|
||||
|
||||
func NewNutrientService(repo *repository.NutrientRepository) *NutrientService {
|
||||
return &NutrientService{repo: repo}
|
||||
}
|
||||
|
||||
func (s *NutrientService) GetAll(ctx context.Context) ([]*models.Nutrient, error) {
|
||||
return s.repo.GetAll(ctx)
|
||||
}
|
||||
43
server/internal/service/supplement_service.go
Normal file
43
server/internal/service/supplement_service.go
Normal file
@@ -0,0 +1,43 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"go-server/internal/models"
|
||||
"go-server/internal/repository"
|
||||
)
|
||||
|
||||
type SupplementService struct {
|
||||
repo *repository.SupplementRepository
|
||||
nutrientRepo *repository.NutrientRepository
|
||||
}
|
||||
|
||||
func NewSupplementService(repo *repository.SupplementRepository, nutrientRepo *repository.NutrientRepository) *SupplementService {
|
||||
return &SupplementService{repo: repo, nutrientRepo: nutrientRepo}
|
||||
}
|
||||
|
||||
func (s *SupplementService) GetAll(ctx context.Context) ([]*models.Supplement, error) {
|
||||
return s.repo.GetAll(ctx)
|
||||
}
|
||||
|
||||
func (s *SupplementService) GetDailySupplementsOverview(ctx context.Context) (*models.DailySupplementsOverview, error) {
|
||||
supplements, err := s.repo.GetAll(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
nutrients, err := s.nutrientRepo.GetAll(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
overview, err := s.repo.GetDailySupplementsOverview(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &models.DailySupplementsOverview{
|
||||
Supplements: supplements,
|
||||
Nutrients: nutrients,
|
||||
Overview: overview,
|
||||
}, nil
|
||||
}
|
||||
181
server/internal/service/todo_service.go
Normal file
181
server/internal/service/todo_service.go
Normal file
@@ -0,0 +1,181 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"go-server/internal/models"
|
||||
"go-server/internal/repository"
|
||||
"time"
|
||||
)
|
||||
|
||||
type TodoService struct {
|
||||
todoRepo *repository.TodoRepository
|
||||
}
|
||||
|
||||
func NewTodoService(todoRepo *repository.TodoRepository) *TodoService {
|
||||
return &TodoService{
|
||||
todoRepo: todoRepo,
|
||||
}
|
||||
}
|
||||
|
||||
// CreateTodoRequest represents the request to create a new todo
|
||||
type CreateTodoRequest struct {
|
||||
Title string `json:"title" validate:"required,min=1,max=200"`
|
||||
Description string `json:"description" validate:"max=1000"`
|
||||
Color string `json:"color" validate:"required,hexcolor"`
|
||||
}
|
||||
|
||||
// UpdateTodoRequest represents the request to update a todo
|
||||
type UpdateTodoRequest struct {
|
||||
Title string `json:"title" validate:"required,min=1,max=200"`
|
||||
Description string `json:"description" validate:"max=1000"`
|
||||
Color string `json:"color" validate:"required,hexcolor"`
|
||||
}
|
||||
|
||||
// CompleteTodoRequest represents the request to complete a todo
|
||||
type CompleteTodoRequest struct {
|
||||
Description string `json:"description" validate:"max=1000"`
|
||||
}
|
||||
|
||||
// CreateTodo creates a new todo for a user
|
||||
func (s *TodoService) CreateTodo(ctx context.Context, userID models.ULID, req CreateTodoRequest) (*models.Todo, error) {
|
||||
todo := &models.Todo{
|
||||
UserID: userID,
|
||||
Title: req.Title,
|
||||
Description: req.Description,
|
||||
Color: req.Color,
|
||||
IsActive: true,
|
||||
}
|
||||
|
||||
err := s.todoRepo.CreateTodo(ctx, todo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return todo, nil
|
||||
}
|
||||
|
||||
// GetTodosWithStats gets all todos for a user with completion statistics
|
||||
func (s *TodoService) GetTodosWithStats(ctx context.Context, userID models.ULID) ([]models.TodoWithStats, error) {
|
||||
return s.todoRepo.GetTodosWithStats(ctx, userID)
|
||||
}
|
||||
|
||||
// GetTodoByID gets a todo by ID (with user verification)
|
||||
func (s *TodoService) GetTodoByID(ctx context.Context, todoID, userID models.ULID) (*models.Todo, error) {
|
||||
return s.todoRepo.GetTodoByID(ctx, todoID, userID)
|
||||
}
|
||||
|
||||
// UpdateTodo updates a todo
|
||||
func (s *TodoService) UpdateTodo(ctx context.Context, todoID, userID models.ULID, req UpdateTodoRequest) (*models.Todo, error) {
|
||||
todo, err := s.todoRepo.GetTodoByID(ctx, todoID, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
todo.Title = req.Title
|
||||
todo.Description = req.Description
|
||||
todo.Color = req.Color
|
||||
|
||||
err = s.todoRepo.UpdateTodo(ctx, todo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return todo, nil
|
||||
}
|
||||
|
||||
// DeleteTodo deletes (deactivates) a todo
|
||||
func (s *TodoService) DeleteTodo(ctx context.Context, todoID, userID models.ULID) error {
|
||||
return s.todoRepo.DeleteTodo(ctx, todoID, userID)
|
||||
}
|
||||
|
||||
// CompleteTodo marks a todo as completed for today
|
||||
func (s *TodoService) CompleteTodo(ctx context.Context, todoID, userID models.ULID, req CompleteTodoRequest) error {
|
||||
// First check if the todo exists and belongs to the user
|
||||
todo, err := s.todoRepo.GetTodoByID(ctx, todoID, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Check if already completed today
|
||||
completedToday, err := s.todoRepo.CheckTodoCompletedToday(ctx, todoID, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if completedToday {
|
||||
return errors.New("todo already completed today")
|
||||
}
|
||||
|
||||
// Create completion record
|
||||
completion := &models.TodoCompletion{
|
||||
TodoID: todo.ID,
|
||||
UserID: userID,
|
||||
CompletedAt: time.Now(),
|
||||
Description: req.Description,
|
||||
}
|
||||
|
||||
return s.todoRepo.CompleteTodo(ctx, completion)
|
||||
}
|
||||
|
||||
// GetTodaysSummary gets today's todo summary
|
||||
func (s *TodoService) GetTodaysSummary(ctx context.Context, userID models.ULID) (*models.DailyTodoSummary, error) {
|
||||
todos, err := s.todoRepo.GetTodosWithStats(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
completedCount := 0
|
||||
for _, todo := range todos {
|
||||
if todo.CompletedToday {
|
||||
completedCount++
|
||||
}
|
||||
}
|
||||
|
||||
summary := &models.DailyTodoSummary{
|
||||
Date: time.Now(),
|
||||
Todos: todos,
|
||||
CompletedCount: completedCount,
|
||||
TotalCount: len(todos),
|
||||
}
|
||||
|
||||
return summary, nil
|
||||
}
|
||||
|
||||
// GetActivityLog gets the user's activity log with pagination
|
||||
func (s *TodoService) GetActivityLog(ctx context.Context, userID models.ULID, limit, offset int) ([]models.TodoCompletion, error) {
|
||||
if limit <= 0 {
|
||||
limit = 50 // Default limit
|
||||
}
|
||||
if limit > 200 {
|
||||
limit = 200 // Max limit
|
||||
}
|
||||
|
||||
return s.todoRepo.GetActivityLog(ctx, userID, limit, offset)
|
||||
}
|
||||
|
||||
// GetActivityLogByDate gets activity log for a specific date
|
||||
func (s *TodoService) GetActivityLogByDate(ctx context.Context, userID models.ULID, date time.Time) ([]models.TodoCompletion, error) {
|
||||
return s.todoRepo.GetActivityLogByDate(ctx, userID, date)
|
||||
}
|
||||
|
||||
// GetWeeklySummary gets a summary of the past 7 days
|
||||
func (s *TodoService) GetWeeklySummary(ctx context.Context, userID models.ULID) (map[string][]models.TodoCompletion, error) {
|
||||
endDate := time.Now().Add(24 * time.Hour) // Include today
|
||||
startDate := endDate.Add(-7 * 24 * time.Hour) // 7 days ago
|
||||
|
||||
completions, err := s.todoRepo.GetCompletionsByDateRange(ctx, userID, startDate, endDate)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Group by date
|
||||
dailyCompletions := make(map[string][]models.TodoCompletion)
|
||||
for _, completion := range completions {
|
||||
dateKey := completion.CompletedAt.Format("2006-01-02")
|
||||
dailyCompletions[dateKey] = append(dailyCompletions[dateKey], completion)
|
||||
}
|
||||
|
||||
return dailyCompletions, nil
|
||||
}
|
||||
|
||||
138
server/internal/service/user_service.go
Normal file
138
server/internal/service/user_service.go
Normal file
@@ -0,0 +1,138 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"go-server/internal/models"
|
||||
"go-server/internal/repository"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
type UserService struct {
|
||||
repo *repository.UserRepository
|
||||
}
|
||||
|
||||
type CreateUserInput struct {
|
||||
Email string
|
||||
Password string
|
||||
FirstName string
|
||||
LastName string
|
||||
}
|
||||
|
||||
type UpdateUserInput struct {
|
||||
FirstName *string
|
||||
LastName *string
|
||||
}
|
||||
|
||||
type ChangePasswordInput struct {
|
||||
Password string
|
||||
}
|
||||
|
||||
func NewUserService(repo *repository.UserRepository) *UserService {
|
||||
return &UserService{repo: repo}
|
||||
}
|
||||
|
||||
func (s *UserService) GetByID(ctx context.Context, id uuid.UUID) (*models.User, error) {
|
||||
return s.repo.GetByID(ctx, id)
|
||||
}
|
||||
|
||||
func (s *UserService) GetAll(ctx context.Context) ([]*models.User, error) {
|
||||
return s.repo.GetAll(ctx)
|
||||
}
|
||||
|
||||
func (s *UserService) Create(ctx context.Context, input CreateUserInput) (*models.User, error) {
|
||||
hashedPassword, err := hashPassword(input.Password)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("password hashing failed: %w", err)
|
||||
}
|
||||
|
||||
user := &models.User{
|
||||
Email: input.Email,
|
||||
Password: hashedPassword,
|
||||
FirstName: input.FirstName,
|
||||
LastName: input.LastName,
|
||||
PlatformRole: models.PlatformRoleUser,
|
||||
}
|
||||
|
||||
if err := s.repo.Create(ctx, user); err != nil {
|
||||
return nil, fmt.Errorf("failed to create user: %w", err)
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (s *UserService) Update(ctx context.Context, id uuid.UUID, input UpdateUserInput) (*models.User, error) {
|
||||
user, err := s.repo.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch user: %w", err)
|
||||
}
|
||||
if user == nil {
|
||||
return nil, errors.New("user not found")
|
||||
}
|
||||
|
||||
if input.FirstName != nil {
|
||||
user.FirstName = *input.FirstName
|
||||
}
|
||||
if input.LastName != nil {
|
||||
user.LastName = *input.LastName
|
||||
}
|
||||
|
||||
if err := s.repo.Update(ctx, user); err != nil {
|
||||
return nil, fmt.Errorf("failed to update user: %w", err)
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (s *UserService) UpdateLastLogin(ctx context.Context, id uuid.UUID) error {
|
||||
user, err := s.repo.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to fetch user: %w", err)
|
||||
}
|
||||
if user == nil {
|
||||
return errors.New("user not found")
|
||||
}
|
||||
|
||||
if err := s.repo.Update(ctx, user); err != nil {
|
||||
return fmt.Errorf("failed to update last login: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *UserService) Delete(ctx context.Context, id uuid.UUID) error {
|
||||
return s.repo.Delete(ctx, id)
|
||||
}
|
||||
|
||||
func (s *UserService) ChangePassword(ctx context.Context, id uuid.UUID, input ChangePasswordInput) error {
|
||||
hashedPassword, err := hashPassword(input.Password)
|
||||
if err != nil {
|
||||
return fmt.Errorf("password hashing failed: %w", err)
|
||||
}
|
||||
|
||||
user, err := s.repo.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to fetch user: %w", err)
|
||||
}
|
||||
if user == nil {
|
||||
return errors.New("user not found")
|
||||
}
|
||||
user.Password = hashedPassword
|
||||
if err := s.repo.Update(ctx, user); err != nil {
|
||||
return fmt.Errorf("failed to update user: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// hashPassword generates a bcrypt hash for a given password.
|
||||
func hashPassword(password string) (string, error) {
|
||||
hashed, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(hashed), nil
|
||||
}
|
||||
37
server/pkg/logger/logger.go
Normal file
37
server/pkg/logger/logger.go
Normal file
@@ -0,0 +1,37 @@
|
||||
// pkg/logger/logger.go
|
||||
package logger
|
||||
|
||||
import (
|
||||
"go.uber.org/zap"
|
||||
"go.uber.org/zap/zapcore"
|
||||
)
|
||||
|
||||
var log *zap.Logger
|
||||
|
||||
func init() {
|
||||
config := zap.NewProductionConfig()
|
||||
config.EncoderConfig.TimeKey = "timestamp"
|
||||
config.EncoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder
|
||||
|
||||
var err error
|
||||
log, err = config.Build()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
func Info(msg string, fields ...zap.Field) {
|
||||
log.Info(msg, fields...)
|
||||
}
|
||||
|
||||
func Error(msg string, fields ...zap.Field) {
|
||||
log.Error(msg, fields...)
|
||||
}
|
||||
|
||||
func Fatal(msg string, fields ...zap.Field) {
|
||||
log.Fatal(msg, fields...)
|
||||
}
|
||||
|
||||
func With(fields ...zap.Field) *zap.Logger {
|
||||
return log.With(fields...)
|
||||
}
|
||||
67
server/pkg/validator/validator.go
Normal file
67
server/pkg/validator/validator.go
Normal file
@@ -0,0 +1,67 @@
|
||||
// pkg/validator/validator.go
|
||||
package validator
|
||||
|
||||
import (
|
||||
"log"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/go-playground/validator/v10"
|
||||
)
|
||||
|
||||
var (
|
||||
validate *validator.Validate
|
||||
emailRegex = regexp.MustCompile(`^[a-z0-9._%+\-]+@[a-z0-9.\-]+\.[a-z]{2,4}$`)
|
||||
)
|
||||
|
||||
func init() {
|
||||
validate = validator.New()
|
||||
|
||||
// Register custom validation
|
||||
err := validate.RegisterValidation("email", validateEmail)
|
||||
if err != nil {
|
||||
log.Print(err)
|
||||
}
|
||||
}
|
||||
|
||||
func validateEmail(fl validator.FieldLevel) bool {
|
||||
return emailRegex.MatchString(fl.Field().String())
|
||||
}
|
||||
|
||||
// ValidationError represents a validation error
|
||||
type ValidationError struct {
|
||||
Field string `json:"field"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// Validate validates a struct and returns validation errors
|
||||
func Validate(i interface{}) []ValidationError {
|
||||
var errors []ValidationError
|
||||
|
||||
err := validate.Struct(i)
|
||||
if err != nil {
|
||||
for _, err := range err.(validator.ValidationErrors) {
|
||||
var element ValidationError
|
||||
element.Field = strings.ToLower(err.Field())
|
||||
element.Message = generateValidationMessage(err)
|
||||
errors = append(errors, element)
|
||||
}
|
||||
}
|
||||
|
||||
return errors
|
||||
}
|
||||
|
||||
func generateValidationMessage(err validator.FieldError) string {
|
||||
switch err.Tag() {
|
||||
case "required":
|
||||
return "This field is required"
|
||||
case "email":
|
||||
return "Invalid email format"
|
||||
case "min":
|
||||
return "Value must be greater than " + err.Param()
|
||||
case "max":
|
||||
return "Value must be less than " + err.Param()
|
||||
default:
|
||||
return "Invalid value"
|
||||
}
|
||||
}
|
||||
41
server/scripts/migration.go
Normal file
41
server/scripts/migration.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package scripts
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"go-server/internal/config"
|
||||
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Define models for migration
|
||||
type User struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
Email string `gorm:"uniqueIndex"`
|
||||
Password string
|
||||
}
|
||||
|
||||
// Migrate function to run database migrations
|
||||
func Migrate() {
|
||||
// Load from environment variables or configuration
|
||||
cfg, err := config.LoadConfig()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
dsn := cfg.Database.DSN()
|
||||
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to connect to database: %v", err)
|
||||
}
|
||||
|
||||
// AutoMigrate runs the migration
|
||||
err = db.AutoMigrate(&User{})
|
||||
if err != nil {
|
||||
log.Fatalf("Migration failed: %v", err)
|
||||
}
|
||||
|
||||
fmt.Println("Migration completed successfully!")
|
||||
}
|
||||
156
web/.gitignore
vendored
Normal file
156
web/.gitignore
vendored
Normal file
@@ -0,0 +1,156 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
.pnp
|
||||
.pnp.js
|
||||
|
||||
# Production builds
|
||||
dist/
|
||||
build/
|
||||
.next/
|
||||
out/
|
||||
|
||||
# Environment files
|
||||
.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
# Logs
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage/
|
||||
*.lcov
|
||||
|
||||
# nyc test coverage
|
||||
.nyc_output
|
||||
|
||||
# Dependency directories
|
||||
jspm_packages/
|
||||
|
||||
# TypeScript cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
# Optional stylelint cache
|
||||
.stylelintcache
|
||||
|
||||
# Microbundle cache
|
||||
.rpt2_cache/
|
||||
.rts2_cache_cjs/
|
||||
.rts2_cache_es/
|
||||
.rts2_cache_umd/
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
.yarn-integrity
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
.cache
|
||||
.parcel-cache
|
||||
|
||||
# Next.js build output
|
||||
.next
|
||||
|
||||
# Nuxt.js build / generate output
|
||||
.nuxt
|
||||
dist
|
||||
|
||||
# Gatsby files
|
||||
.cache/
|
||||
public
|
||||
|
||||
# Vuepress build output
|
||||
.vuepress/dist
|
||||
|
||||
# Serverless directories
|
||||
.serverless/
|
||||
|
||||
# FuseBox cache
|
||||
.fusebox/
|
||||
|
||||
# DynamoDB Local files
|
||||
.dynamodb/
|
||||
|
||||
# TernJS port file
|
||||
.tern-port
|
||||
|
||||
# Stores VSCode versions used for testing VSCode extensions
|
||||
.vscode-test
|
||||
|
||||
# yarn v2
|
||||
.yarn/cache
|
||||
.yarn/unplugged
|
||||
.yarn/build-state.yml
|
||||
.yarn/install-state.gz
|
||||
.pnp.*
|
||||
|
||||
# Turbo
|
||||
.turbo
|
||||
|
||||
# OS generated files
|
||||
.DS_Store
|
||||
.DS_Store?
|
||||
._*
|
||||
.Spotlight-V100
|
||||
.Trashes
|
||||
ehthumbs.db
|
||||
Thumbs.db
|
||||
|
||||
# IDE
|
||||
.vscode/launch.json
|
||||
.vscode/tasks.json
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# Temporary files
|
||||
*.tmp
|
||||
*.temp
|
||||
|
||||
# Database
|
||||
*.db
|
||||
*.sqlite
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
|
||||
# SQL dumps
|
||||
*.sql
|
||||
|
||||
# SonarQube
|
||||
.scannerwork/
|
||||
server/.scannerwork/
|
||||
biome-report.json
|
||||
eslint-report.json
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
|
||||
.history
|
||||
|
||||
bundle-analysis.html
|
||||
142
web/biome.json
Normal file
142
web/biome.json
Normal file
@@ -0,0 +1,142 @@
|
||||
{
|
||||
"$schema": "./node_modules/@biomejs/biome/configuration_schema.json",
|
||||
"vcs": {
|
||||
"enabled": true,
|
||||
"clientKind": "git",
|
||||
"useIgnoreFile": true
|
||||
},
|
||||
"files": {
|
||||
"ignoreUnknown": false,
|
||||
"includes": [
|
||||
"**",
|
||||
"!**/node_modules/**",
|
||||
"!**/dist/**",
|
||||
"!**/build/**",
|
||||
"!**/.turbo/**",
|
||||
"!**/coverage/**",
|
||||
"!**/*.min.js",
|
||||
"!**/*.bundle.js",
|
||||
"!**/public/**",
|
||||
"!**/static/**",
|
||||
"!**/migration/**/*.ts"
|
||||
]
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"formatWithErrors": false,
|
||||
"indentStyle": "space",
|
||||
"indentWidth": 2,
|
||||
"lineEnding": "lf",
|
||||
"lineWidth": 160,
|
||||
"attributePosition": "auto"
|
||||
},
|
||||
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"recommended": true,
|
||||
"complexity": {
|
||||
"noExtraBooleanCast": "error",
|
||||
"noUselessCatch": "error",
|
||||
"noUselessTypeConstraint": "error",
|
||||
"noStaticOnlyClass": "off",
|
||||
"noThisInStatic": "off"
|
||||
},
|
||||
"correctness": {
|
||||
"noConstAssign": "error",
|
||||
"noConstantCondition": "error",
|
||||
"noEmptyCharacterClassInRegex": "error",
|
||||
"noEmptyPattern": "error",
|
||||
"noGlobalObjectCalls": "error",
|
||||
"noInvalidConstructorSuper": "error",
|
||||
"noNonoctalDecimalEscape": "error",
|
||||
"noPrecisionLoss": "error",
|
||||
"noSelfAssign": "error",
|
||||
"noSetterReturn": "error",
|
||||
"noSwitchDeclarations": "error",
|
||||
"noUndeclaredVariables": "error",
|
||||
"noUnreachable": "error",
|
||||
"noUnreachableSuper": "error",
|
||||
"noUnsafeFinally": "error",
|
||||
"noUnsafeOptionalChaining": "error",
|
||||
"noUnusedLabels": "error",
|
||||
"noUnusedVariables": "error",
|
||||
"useIsNan": "error",
|
||||
"useValidForDirection": "error",
|
||||
"useYield": "error"
|
||||
},
|
||||
"style": {
|
||||
"useConst": "error",
|
||||
"useTemplate": "error",
|
||||
"noNonNullAssertion": "off",
|
||||
"noParameterAssign": "error",
|
||||
"useAsConstAssertion": "error",
|
||||
"useDefaultParameterLast": "error",
|
||||
"useEnumInitializers": "error",
|
||||
"useSelfClosingElements": "error",
|
||||
"useSingleVarDeclarator": "error",
|
||||
"noUnusedTemplateLiteral": "error",
|
||||
"useNumberNamespace": "error",
|
||||
"noInferrableTypes": "error",
|
||||
"noUselessElse": "error"
|
||||
},
|
||||
"suspicious": {
|
||||
"noAsyncPromiseExecutor": "error",
|
||||
"noCatchAssign": "error",
|
||||
"noClassAssign": "error",
|
||||
"noCompareNegZero": "error",
|
||||
"noControlCharactersInRegex": "error",
|
||||
"noDebugger": "error",
|
||||
"noDuplicateCase": "error",
|
||||
"noDuplicateClassMembers": "error",
|
||||
"noDuplicateObjectKeys": "error",
|
||||
"noDuplicateParameters": "error",
|
||||
"noEmptyBlockStatements": "error",
|
||||
"noFallthroughSwitchClause": "error",
|
||||
"noFunctionAssign": "error",
|
||||
"noGlobalAssign": "error",
|
||||
"noImportAssign": "error",
|
||||
"noMisleadingCharacterClass": "error",
|
||||
"noPrototypeBuiltins": "error",
|
||||
"noRedeclare": "error",
|
||||
"noShadowRestrictedNames": "error",
|
||||
"noUnsafeNegation": "error",
|
||||
"noEmptyInterface": "off",
|
||||
"noArrayIndexKey": "off"
|
||||
},
|
||||
"performance": {
|
||||
"noAccumulatingSpread": "off"
|
||||
}
|
||||
}
|
||||
},
|
||||
"javascript": {
|
||||
"formatter": {
|
||||
"jsxQuoteStyle": "double",
|
||||
"quoteProperties": "asNeeded",
|
||||
"trailingCommas": "all",
|
||||
"semicolons": "always",
|
||||
"arrowParentheses": "always",
|
||||
"bracketSpacing": true,
|
||||
"bracketSameLine": false,
|
||||
"quoteStyle": "double",
|
||||
"attributePosition": "auto"
|
||||
},
|
||||
"parser": {
|
||||
"unsafeParameterDecoratorsEnabled": true
|
||||
}
|
||||
},
|
||||
|
||||
"json": {
|
||||
"parser": {
|
||||
"allowComments": true
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"indentStyle": "space",
|
||||
"indentWidth": 2,
|
||||
"lineEnding": "lf",
|
||||
"lineWidth": 80,
|
||||
"trailingCommas": "none"
|
||||
}
|
||||
}
|
||||
}
|
||||
32
web/components/package.json
Normal file
32
web/components/package.json
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "@greatness/components",
|
||||
"version": "1.0.0",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"scripts": {
|
||||
"test": "jest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@greatness/util": "workspace:*",
|
||||
"@types/node": "24.1.0",
|
||||
"@types/react": "19.1.8",
|
||||
"@types/react-dom": "19.1.6",
|
||||
"@carbon/icons-react": "11.63.0",
|
||||
"@radix-ui/react-select": "2.2.5",
|
||||
"lucide-react": "0.525.0",
|
||||
"@hookform/resolvers": "5.2.0",
|
||||
"@floating-ui/react": "0.27.13",
|
||||
"react-jss": "10.10.0",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"react-error-boundary": "6.0.0",
|
||||
"react-router-dom": "7.7.1",
|
||||
"clsx": "2.1.1",
|
||||
"tailwind-merge": "3.3.1",
|
||||
"react-loading-skeleton": "3.5.0",
|
||||
"react-hook-form": "7.61.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"jest": "30.0.5"
|
||||
}
|
||||
}
|
||||
78
web/components/src/AlertProvider.tsx
Normal file
78
web/components/src/AlertProvider.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import { ErrorOutline, CheckmarkFilled } from "@carbon/icons-react";
|
||||
import { noop } from "@greatness/util";
|
||||
|
||||
import { cn } from "@/util/cn";
|
||||
|
||||
import { createContext, useContext, useState } from "react";
|
||||
|
||||
interface IAlert {
|
||||
message?: string;
|
||||
severity?: "success" | "error";
|
||||
title?: string;
|
||||
}
|
||||
|
||||
interface IAlertState {
|
||||
addAlert: (alert: IAlert) => void;
|
||||
alerts: IAlert[];
|
||||
}
|
||||
|
||||
const AlertContext = createContext<IAlertState>({
|
||||
addAlert: noop,
|
||||
alerts: [],
|
||||
});
|
||||
|
||||
export const useAlert = (): IAlertState => useContext(AlertContext);
|
||||
|
||||
export default function AlertProvider({
|
||||
children,
|
||||
}: React.PropsWithChildren) {
|
||||
const [alerts, setAlerts] = useState<IAlert[]>([]);
|
||||
|
||||
const dismissAlert = (alertToDismiss: IAlert) =>
|
||||
setAlerts((currentAlerts) =>
|
||||
currentAlerts.filter((currentAlert) => currentAlert !== alertToDismiss),
|
||||
);
|
||||
|
||||
const addAlert = (alert: IAlert) => {
|
||||
setAlerts((prev) => {
|
||||
setTimeout(() => dismissAlert(alert), 10000); // 10 sec alert for now
|
||||
return [...prev, alert];
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<AlertContext.Provider value={{ addAlert, alerts }}>
|
||||
{children}
|
||||
<div className="fixed top-0 right-4 top-4 z-50">
|
||||
{alerts.map((alert, idx) => (
|
||||
<div
|
||||
role="presentation"
|
||||
key={[alert.title, idx].join("-")}
|
||||
onClick={() => dismissAlert(alert)}
|
||||
className={cn("flex items-center justify-between bg-black text-white border-white p-2 cursor-pointer", {
|
||||
"bg-green-500": alert.severity === "success",
|
||||
"bg-red-500": alert.severity === "error",
|
||||
})}
|
||||
>
|
||||
<div className="flex items-center justify-center w-6 h-6">
|
||||
{alert.severity === "error" ? (
|
||||
<ErrorOutline />
|
||||
) : (
|
||||
<CheckmarkFilled />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
{alert.title && (
|
||||
<span className="text-xs font-medium">{alert.title}</span>
|
||||
)}
|
||||
<span className="text-xs">{alert.message}</span>
|
||||
</div>
|
||||
{/* <div className="flex items-center justify-center w-6 h-6">
|
||||
<Exit />
|
||||
</div> */}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</AlertContext.Provider>
|
||||
);
|
||||
}
|
||||
167
web/components/src/Button.tsx
Normal file
167
web/components/src/Button.tsx
Normal file
@@ -0,0 +1,167 @@
|
||||
import { useCallback } from "react";
|
||||
|
||||
import createUseStyles from "./theme/createUseStyles";
|
||||
|
||||
import { applySpinnerAnimation, spinnerAnimation } from "./loading/Spinner";
|
||||
import { cn } from "./util/cn";
|
||||
|
||||
const useStyles = createUseStyles<{
|
||||
color?: string;
|
||||
disabled: boolean;
|
||||
isLoading: boolean;
|
||||
size: ButtonSize;
|
||||
}>({
|
||||
button: {
|
||||
"&:hover": {
|
||||
backgroundColor: (options) => {
|
||||
if (options.disabled) {
|
||||
return "var(--color-darkGrey)";
|
||||
}
|
||||
switch (options.color) {
|
||||
case "var(--color-white)":
|
||||
return "var(--color-lightGreen)";
|
||||
case "var(--color-grey)":
|
||||
return "var(--color-darkGrey)";
|
||||
case "var(--color-green)":
|
||||
return "var(--color-darkGreen)";
|
||||
case "var(--color-lightRed)":
|
||||
return "var(--color-semiLightRed)";
|
||||
case "var(--color-semiLightRed)":
|
||||
return "var(--color-lightRed)";
|
||||
case "var(--color-black)":
|
||||
return "rgba(255,255,255,0.1)";
|
||||
default:
|
||||
return options.color;
|
||||
}
|
||||
},
|
||||
},
|
||||
backgroundColor: (options) =>
|
||||
options.disabled ? "var(--color-darkGrey)" : options.color,
|
||||
border: (options) => {
|
||||
switch (options.color) {
|
||||
case "var(--color-black)":
|
||||
return "1px solid var(--color-white)";
|
||||
default:
|
||||
return "none";
|
||||
}
|
||||
},
|
||||
borderRadius: 0,
|
||||
cursor: ({ disabled }) => (disabled ? "not-allowed" : "pointer"),
|
||||
padding: ({ size }) => {
|
||||
switch (size) {
|
||||
case ButtonSize.S:
|
||||
return "var(--spacing-xs) var(--spacing-m)";
|
||||
default:
|
||||
case ButtonSize.M:
|
||||
return "var(--spacing-m) var(--spacing-l)";
|
||||
}
|
||||
},
|
||||
},
|
||||
buttonLabel: {
|
||||
color: (options) => {
|
||||
if (options.disabled) {
|
||||
return "var(--color-white)";
|
||||
}
|
||||
switch (options.color) {
|
||||
case "var(--color-white)":
|
||||
return "var(--color-black)";
|
||||
case "var(--color-grey)":
|
||||
return "var(--color-black)";
|
||||
default:
|
||||
return "var(--color-white)";
|
||||
}
|
||||
},
|
||||
fontWeight: 700,
|
||||
},
|
||||
...spinnerAnimation,
|
||||
buttonContent: {
|
||||
display: ({ isLoading }) => (isLoading ? "inline-flex" : "flex"),
|
||||
opacity: ({ isLoading }) => (isLoading ? 0 : 1),
|
||||
},
|
||||
loading: {
|
||||
border: "3px solid var(--color-whiteOpacity)",
|
||||
borderTopColor: "var(--color-white)",
|
||||
height: 25,
|
||||
left: 0,
|
||||
right: 0,
|
||||
width: 25,
|
||||
...applySpinnerAnimation,
|
||||
},
|
||||
});
|
||||
|
||||
export enum ButtonSize {
|
||||
M = "M",
|
||||
S = "S",
|
||||
}
|
||||
|
||||
export default function Button({
|
||||
children,
|
||||
label,
|
||||
className,
|
||||
isDisabled = false,
|
||||
onClick,
|
||||
color = "var(--color-black)",
|
||||
type = "button",
|
||||
isLoading = false,
|
||||
prefix,
|
||||
size = ButtonSize.M,
|
||||
isUppercase = true,
|
||||
...buttonAttributes
|
||||
}: React.PropsWithChildren<{
|
||||
className?: string;
|
||||
color?: string;
|
||||
isDisabled?: boolean;
|
||||
isLoading?: boolean;
|
||||
isUppercase?: boolean;
|
||||
label: React.ReactNode;
|
||||
onClick?: (
|
||||
event: React.MouseEvent<HTMLButtonElement>,
|
||||
) => void | Promise<void>;
|
||||
prefix?: React.ReactNode;
|
||||
size?: ButtonSize;
|
||||
type?: React.ButtonHTMLAttributes<HTMLButtonElement>["type"];
|
||||
style?: React.CSSProperties;
|
||||
} & Partial<Pick<React.ButtonHTMLAttributes<HTMLButtonElement>, "style" | "onMouseEnter" | "onMouseLeave">>>) {
|
||||
const classes = useStyles({
|
||||
color,
|
||||
disabled: isDisabled,
|
||||
isLoading,
|
||||
size,
|
||||
});
|
||||
|
||||
const onClickWrapper = useCallback(
|
||||
(e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
if (isDisabled) {
|
||||
return;
|
||||
}
|
||||
if (onClick) {
|
||||
void onClick(e);
|
||||
}
|
||||
},
|
||||
[isDisabled, onClick],
|
||||
);
|
||||
|
||||
return (
|
||||
<button
|
||||
// eslint-disable-next-line react/button-has-type
|
||||
type={type}
|
||||
className={cn(classes.button, "relative user-select-none word-break-keep-all outline-none", className)}
|
||||
{...(isDisabled && { disabled: isDisabled })}
|
||||
{...(!isDisabled && onClick && { onClick: onClickWrapper })}
|
||||
{...buttonAttributes}
|
||||
>
|
||||
{isLoading && <div className={cn("margin-auto absolute inline-block border-radius-round", classes.loading)} />}
|
||||
<div className={cn("flex-row items-center", classes.buttonContent)}>
|
||||
{prefix}
|
||||
<span
|
||||
className={cn("flex-1 font-size-s font-letterSpacing-m font-lineHeight-l text-center", classes.buttonLabel, {
|
||||
"text-uppercase": isUppercase,
|
||||
})}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
{children}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
124
web/components/src/Collapsible.tsx
Normal file
124
web/components/src/Collapsible.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import { TextUtil } from "@greatness/util";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import ComponentErrorBoundary from "./ComponentErrorBoundary";
|
||||
import createUseStyles from "./theme/createUseStyles";
|
||||
import { cn } from "./util/cn";
|
||||
|
||||
const useStyles = createUseStyles({
|
||||
collapsible: {
|
||||
"& h2, & h3, & h4": {
|
||||
margin: 0,
|
||||
},
|
||||
appearance: "none",
|
||||
},
|
||||
});
|
||||
|
||||
class CollapsibleStorage {
|
||||
static set(blockKey: string, isCollapsed: boolean) {
|
||||
const key = CollapsibleStorage.getKey(blockKey);
|
||||
const value = isCollapsed ? "true" : "false";
|
||||
localStorage.setItem(key, value);
|
||||
}
|
||||
|
||||
static isCollapsed(blockKey: string) {
|
||||
const storageValue = localStorage.getItem(CollapsibleStorage.getKey(blockKey));
|
||||
if (storageValue === null) {
|
||||
return true;
|
||||
}
|
||||
return storageValue === "true";
|
||||
}
|
||||
|
||||
static getKey(blockKey: string) {
|
||||
return `is-collapsed-${blockKey}`;
|
||||
}
|
||||
}
|
||||
|
||||
const useCollapsible = ({
|
||||
blockKey: blockKeyProp,
|
||||
title,
|
||||
}: {
|
||||
blockKey?: string;
|
||||
title: string | React.ReactNode;
|
||||
}) => {
|
||||
const blockKey = (() => {
|
||||
if (!TextUtil.isEmpty(blockKeyProp)) {
|
||||
return blockKeyProp!;
|
||||
}
|
||||
if (typeof title === "string") {
|
||||
return title;
|
||||
}
|
||||
throw new Error("Invalid block key");
|
||||
})();
|
||||
|
||||
const [isCollapsed, setIsCollapsed] = useState(
|
||||
CollapsibleStorage.isCollapsed(blockKey),
|
||||
);
|
||||
|
||||
const toggleVisible = useCallback(() => {
|
||||
setIsCollapsed((prev) => {
|
||||
CollapsibleStorage.set(blockKey, !prev);
|
||||
return !prev;
|
||||
});
|
||||
}, [blockKey]);
|
||||
|
||||
const onError = useCallback((error: Error) => {
|
||||
console.error("Error in Collapsible", error);
|
||||
CollapsibleStorage.set(blockKey, true);
|
||||
setIsCollapsed(true);
|
||||
}, [blockKey]);
|
||||
|
||||
return {
|
||||
isCollapsed,
|
||||
toggleVisible,
|
||||
onError,
|
||||
};
|
||||
};
|
||||
|
||||
export default function Collapsible({
|
||||
Title = "h4",
|
||||
blockKey: blockKeyProp,
|
||||
children,
|
||||
color,
|
||||
title,
|
||||
onIsCollapsedChange,
|
||||
}: React.PropsWithChildren<{
|
||||
Title?: "h2" | "h3" | "h4";
|
||||
blockKey?: string;
|
||||
color?: string;
|
||||
title: string | React.ReactNode;
|
||||
onIsCollapsedChange?: (isCollapsed: boolean) => void;
|
||||
}>) {
|
||||
const classes = useStyles();
|
||||
|
||||
const { isCollapsed, toggleVisible, onError } = useCollapsible({
|
||||
blockKey: blockKeyProp,
|
||||
title,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (onIsCollapsedChange) {
|
||||
onIsCollapsedChange(isCollapsed);
|
||||
}
|
||||
}, [isCollapsed, onIsCollapsedChange]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className={cn("items-center flex gap-m outline-none cursor-pointer w-full py-s px-m my-m bg-greyOpacity border-none", classes.collapsible)}
|
||||
onClick={toggleVisible}
|
||||
style={{
|
||||
...(!TextUtil.isEmpty(color) && {
|
||||
background: color!,
|
||||
}),
|
||||
}}
|
||||
>
|
||||
{typeof title === "string" ? <Title>{title}</Title> : title}
|
||||
</button>
|
||||
<ComponentErrorBoundary onError={onError}>
|
||||
{!isCollapsed && children}
|
||||
</ComponentErrorBoundary>
|
||||
</>
|
||||
);
|
||||
};
|
||||
45
web/components/src/ComponentErrorBoundary.tsx
Normal file
45
web/components/src/ComponentErrorBoundary.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { FallbackProps } from "react-error-boundary";
|
||||
import { ErrorBoundary } from "react-error-boundary";
|
||||
import { useLocation } from "react-router-dom";
|
||||
|
||||
function ErrorFallback({ error, resetErrorBoundary, onError }: FallbackProps & { onError?: (error: Error) => void }) {
|
||||
const location = useLocation();
|
||||
|
||||
const [initialLocation] = useState(location.pathname);
|
||||
|
||||
useEffect(() => {
|
||||
if (onError) {
|
||||
onError(error);
|
||||
resetErrorBoundary();
|
||||
return;
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions
|
||||
if (location.pathname !== initialLocation || !error) {
|
||||
resetErrorBoundary();
|
||||
} else {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("Error occurred", error);
|
||||
}
|
||||
}, [error, location.pathname, initialLocation, resetErrorBoundary, onError]);
|
||||
|
||||
return (
|
||||
<div className="h-full w-full p-l">
|
||||
<h1>Something went wrong!</h1>
|
||||
<p>Please try again later.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ComponentErrorBoundary({
|
||||
children,
|
||||
onError,
|
||||
}: React.PropsWithChildren<{
|
||||
onError?: (error: Error) => void;
|
||||
}>) {
|
||||
return (
|
||||
<ErrorBoundary FallbackComponent={(props) => <ErrorFallback {...props} onError={onError} />}>
|
||||
{children}
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
8
web/components/src/LayoutWrapper.tsx
Normal file
8
web/components/src/LayoutWrapper.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
import { useOutlet } from "react-router-dom";
|
||||
|
||||
import ComponentErrorBoundary from "./ComponentErrorBoundary";
|
||||
|
||||
export default function LayoutWrapper() {
|
||||
const outlet = useOutlet();
|
||||
return <ComponentErrorBoundary>{outlet}</ComponentErrorBoundary>;
|
||||
}
|
||||
47
web/components/src/Modal.tsx
Normal file
47
web/components/src/Modal.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
import createUseStyles from "./theme/createUseStyles";
|
||||
import { cn } from "./util/cn";
|
||||
|
||||
const useStyles = createUseStyles(({ media }) => ({
|
||||
modal: {
|
||||
[media.md]: {
|
||||
padding: "var(--spacing-m) var(--spacing-xl)",
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
export const MODAL_BACKDROP_TIMEOUT = 250;
|
||||
|
||||
export default function Modal({
|
||||
children,
|
||||
handleClose,
|
||||
isOpen,
|
||||
}: React.PropsWithChildren<{
|
||||
handleClose: () => void;
|
||||
isOpen: boolean;
|
||||
}>) {
|
||||
const classes = useStyles();
|
||||
|
||||
const ref = useRef<HTMLDialogElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
ref.current?.showModal();
|
||||
} else {
|
||||
ref.current?.close();
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
return (
|
||||
<dialog
|
||||
ref={ref}
|
||||
onCancel={handleClose}
|
||||
className={cn("overflow-auto justify-center items-center border-white bg-black py-xxl px-m shadow-none", classes.modal, {
|
||||
["flex"]: isOpen,
|
||||
})}
|
||||
>
|
||||
{children}
|
||||
</dialog>
|
||||
);
|
||||
}
|
||||
35
web/components/src/StatusIndicator.tsx
Normal file
35
web/components/src/StatusIndicator.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import createUseStyles from "./theme/createUseStyles";
|
||||
import { cn } from "./util/cn";
|
||||
|
||||
const useStyles = createUseStyles({
|
||||
root: {
|
||||
height: 10,
|
||||
width: 10,
|
||||
},
|
||||
});
|
||||
|
||||
export default function StatusIndicator({
|
||||
active = false,
|
||||
positive = false,
|
||||
intermediary = false,
|
||||
negative = false,
|
||||
}: {
|
||||
active?: boolean;
|
||||
intermediary?: boolean;
|
||||
negative?: boolean;
|
||||
positive?: boolean;
|
||||
}) {
|
||||
const classes = useStyles();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("inline-block cursor-pointer border-radius-round", classes.root, {
|
||||
["bg-grey"]: !active && !positive && !intermediary && !negative,
|
||||
["bg-black"]: active,
|
||||
["bg-green"]: positive,
|
||||
["bg-warningOrange"]: intermediary,
|
||||
["bg-lightRed"]: negative,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
71
web/components/src/Tooltip.tsx
Normal file
71
web/components/src/Tooltip.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import {
|
||||
autoPlacement,
|
||||
autoUpdate,
|
||||
offset,
|
||||
shift,
|
||||
useFloating,
|
||||
useHover,
|
||||
useInteractions,
|
||||
} from "@floating-ui/react";
|
||||
import type React from "react";
|
||||
import { useState } from "react";
|
||||
|
||||
import createUseStyles from "./theme/createUseStyles";
|
||||
import { cn } from "./util/cn";
|
||||
|
||||
const useStyles = createUseStyles({
|
||||
tooltip: {
|
||||
fontWeight: "normal",
|
||||
opacity: 0.9,
|
||||
zIndex: 1000,
|
||||
},
|
||||
});
|
||||
|
||||
export default function Tooltip({
|
||||
children,
|
||||
content,
|
||||
isDisabled = false,
|
||||
offsetDistance = 4,
|
||||
}: React.PropsWithChildren<{
|
||||
content: React.ReactNode;
|
||||
isDisabled?: boolean;
|
||||
offsetDistance?: number;
|
||||
}>) {
|
||||
const classes = useStyles();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const { refs, floatingStyles, context } = useFloating({
|
||||
middleware: [offset(offsetDistance), autoPlacement(), shift()],
|
||||
onOpenChange: setIsOpen,
|
||||
open: isOpen && !isDisabled,
|
||||
whileElementsMounted: autoUpdate,
|
||||
});
|
||||
|
||||
const hover = useHover(context);
|
||||
const { getReferenceProps, getFloatingProps } = useInteractions([hover]);
|
||||
|
||||
if (isDisabled || content === undefined) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
ref={refs.setReference}
|
||||
{...getReferenceProps()}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
{isOpen && (
|
||||
<div
|
||||
ref={refs.setFloating}
|
||||
style={floatingStyles}
|
||||
className={cn("bg-black border-white border-radius-s text-white font-size-xs font-lineHeight-s py-xxs px-s", classes.tooltip)}
|
||||
{...getFloatingProps()}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
24
web/components/src/confirm/ConfirmContext.ts
Normal file
24
web/components/src/confirm/ConfirmContext.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { noop } from "@greatness/util";
|
||||
import { createContext } from "react";
|
||||
|
||||
export interface IConfirmContextState {
|
||||
show: boolean;
|
||||
text: string | null;
|
||||
}
|
||||
|
||||
interface IConfirmContextActions {
|
||||
closeConfirm: () => void;
|
||||
showConfirm: (text: string) => void;
|
||||
}
|
||||
|
||||
const ConfirmContext = createContext<
|
||||
[IConfirmContextState, IConfirmContextActions]
|
||||
>([
|
||||
{
|
||||
show: false,
|
||||
text: null,
|
||||
},
|
||||
{ closeConfirm: noop, showConfirm: noop },
|
||||
]);
|
||||
|
||||
export default ConfirmContext;
|
||||
67
web/components/src/confirm/ConfirmDialog.tsx
Normal file
67
web/components/src/confirm/ConfirmDialog.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import "react-loading-skeleton/dist/skeleton.css";
|
||||
|
||||
import { TextUtil } from "@greatness/util";
|
||||
import Skeleton from "react-loading-skeleton";
|
||||
|
||||
import Button from "../Button";
|
||||
import Modal from "../Modal";
|
||||
import createUseStyles from "../theme/createUseStyles";
|
||||
import { cn } from "../util/cn";
|
||||
|
||||
import useConfirm from "./useConfirm";
|
||||
|
||||
const useStyles = createUseStyles({
|
||||
buttonsWrapper: {
|
||||
"& button": {
|
||||
flex: 1,
|
||||
maxWidth: 125,
|
||||
wordBreak: "keep-all",
|
||||
},
|
||||
},
|
||||
modalButton: {
|
||||
"& > div": {
|
||||
justifyContent: "center",
|
||||
},
|
||||
},
|
||||
modalContent: {
|
||||
width: 350,
|
||||
},
|
||||
});
|
||||
|
||||
export default function ConfirmDialog({
|
||||
confirmText,
|
||||
cancelText,
|
||||
}: {
|
||||
confirmText: string;
|
||||
cancelText: string;
|
||||
}) {
|
||||
const classes = useStyles();
|
||||
const { onCancel, onConfirm, confirmState } = useConfirm();
|
||||
|
||||
return (
|
||||
<Modal isOpen={confirmState.show} handleClose={onCancel}>
|
||||
<div className={cn("flex flex-col", classes.modalContent)}>
|
||||
<h5>
|
||||
{TextUtil.isEmpty(confirmState.text) ? (
|
||||
<Skeleton width={325} height={28} />
|
||||
) : (
|
||||
confirmState.text
|
||||
)}
|
||||
</h5>
|
||||
<div className={cn("flex gap-m justify-center", classes.buttonsWrapper)}>
|
||||
<Button
|
||||
onClick={onConfirm}
|
||||
label={confirmText}
|
||||
className={classes.modalButton}
|
||||
/>
|
||||
<Button
|
||||
color="var(--color-semiLightRed)"
|
||||
onClick={onCancel}
|
||||
label={cancelText}
|
||||
className={classes.modalButton}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
44
web/components/src/confirm/ConfirmProvider.tsx
Normal file
44
web/components/src/confirm/ConfirmProvider.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { useCallback, useMemo, useReducer } from "react";
|
||||
|
||||
import ConfirmContext from "./ConfirmContext";
|
||||
import {
|
||||
HIDE_CONFIRM,
|
||||
initialState,
|
||||
reducer,
|
||||
SHOW_CONFIRM,
|
||||
} from "./ConfirmReducer";
|
||||
|
||||
export default function ConfirmContextProvider({
|
||||
children,
|
||||
}: React.PropsWithChildren) {
|
||||
const [state, dispatch] = useReducer(reducer, initialState);
|
||||
|
||||
const showConfirm = useCallback((text: string) => {
|
||||
dispatch({
|
||||
payload: {
|
||||
text,
|
||||
},
|
||||
type: SHOW_CONFIRM,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const closeConfirm = useCallback(() => {
|
||||
dispatch({
|
||||
type: HIDE_CONFIRM,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const actions = useMemo(
|
||||
() => ({
|
||||
closeConfirm,
|
||||
showConfirm,
|
||||
}),
|
||||
[showConfirm, closeConfirm],
|
||||
);
|
||||
|
||||
return (
|
||||
<ConfirmContext.Provider value={[state, actions]}>
|
||||
{children}
|
||||
</ConfirmContext.Provider>
|
||||
);
|
||||
}
|
||||
28
web/components/src/confirm/ConfirmReducer.ts
Normal file
28
web/components/src/confirm/ConfirmReducer.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { Reducer } from "react";
|
||||
|
||||
import type { IConfirmContextState } from "./ConfirmContext";
|
||||
|
||||
export const SHOW_CONFIRM = "SHOW_CONFIRM";
|
||||
export const HIDE_CONFIRM = "HIDE_CONFIRM";
|
||||
|
||||
export const initialState: IConfirmContextState = {
|
||||
show: false,
|
||||
text: null,
|
||||
};
|
||||
|
||||
export const reducer: Reducer<
|
||||
IConfirmContextState,
|
||||
{ type: "HIDE_CONFIRM" } | { payload: { text: string }; type: "SHOW_CONFIRM" }
|
||||
> = (_, action) => {
|
||||
switch (action.type) {
|
||||
case SHOW_CONFIRM:
|
||||
return {
|
||||
show: true,
|
||||
text: action.payload.text,
|
||||
};
|
||||
case HIDE_CONFIRM:
|
||||
return initialState;
|
||||
default:
|
||||
return initialState;
|
||||
}
|
||||
};
|
||||
38
web/components/src/confirm/useConfirm.ts
Normal file
38
web/components/src/confirm/useConfirm.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { useCallback, useContext } from "react";
|
||||
|
||||
import { MODAL_BACKDROP_TIMEOUT } from "../Modal";
|
||||
|
||||
import ConfirmContext from "./ConfirmContext";
|
||||
|
||||
type ResolveCallback = ((value: unknown) => void) | null;
|
||||
|
||||
let resolveCallback: ResolveCallback = null;
|
||||
|
||||
export default function useConfirm() {
|
||||
const [confirmState, { closeConfirm, showConfirm }] =
|
||||
useContext(ConfirmContext);
|
||||
|
||||
const onConfirm = () => {
|
||||
closeConfirm();
|
||||
resolveCallback?.(true);
|
||||
};
|
||||
|
||||
const onCancel = () => {
|
||||
closeConfirm();
|
||||
resolveCallback?.(false);
|
||||
};
|
||||
|
||||
const confirm = useCallback(
|
||||
async (text: string) => {
|
||||
showConfirm(text);
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
resolveCallback = resolve;
|
||||
}, MODAL_BACKDROP_TIMEOUT);
|
||||
});
|
||||
},
|
||||
[showConfirm],
|
||||
);
|
||||
|
||||
return { confirm, confirmState, onCancel, onConfirm };
|
||||
}
|
||||
76
web/components/src/form/Form.tsx
Normal file
76
web/components/src/form/Form.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import { noop } from "@greatness/util";
|
||||
import { yupResolver } from "@hookform/resolvers/yup";
|
||||
import { useMemo } from "react";
|
||||
import type {
|
||||
DefaultValues,
|
||||
FieldValues,
|
||||
RegisterOptions,
|
||||
UseFormReset,
|
||||
} from "react-hook-form";
|
||||
import { FormProvider, useForm } from "react-hook-form";
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
export type ResetForm<T extends FieldValues = any> = UseFormReset<T>;
|
||||
export type FormValidation = any;
|
||||
/* eslint-enable @typescript-eslint/no-explicit-any */
|
||||
type HandleSubmit<T extends FieldValues> = (values: T) => Promise<void>;
|
||||
|
||||
type FormDefaultValues = Record<
|
||||
string,
|
||||
unknown[] | boolean | number | string | undefined
|
||||
>;
|
||||
export interface IFormProperties<T extends FieldValues = FormDefaultValues> {
|
||||
autocomplete?: string;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
defaultValues?: DefaultValues<T>;
|
||||
handleSubmit?: HandleSubmit<T>;
|
||||
isDisabled?: boolean;
|
||||
validation?: FormValidation;
|
||||
}
|
||||
export interface IFormError {
|
||||
message: string;
|
||||
type: keyof RegisterOptions | "email" | "manual";
|
||||
}
|
||||
|
||||
const Form = <T extends FieldValues>(
|
||||
props: IFormProperties<T>,
|
||||
): React.ReactElement => {
|
||||
const {
|
||||
children,
|
||||
handleSubmit: handleSubmitProp = noop,
|
||||
validation,
|
||||
className,
|
||||
defaultValues,
|
||||
autocomplete,
|
||||
} = props;
|
||||
const methods = useForm<T>({
|
||||
defaultValues,
|
||||
mode: "onBlur",
|
||||
...(validation && {
|
||||
resolver: yupResolver(validation),
|
||||
}),
|
||||
});
|
||||
|
||||
const { handleSubmit } = methods;
|
||||
|
||||
const onSubmit = useMemo(
|
||||
() => handleSubmit(async (values) => handleSubmitProp(values)),
|
||||
[handleSubmit, handleSubmitProp],
|
||||
);
|
||||
|
||||
return (
|
||||
<FormProvider {...methods}>
|
||||
<form
|
||||
className={className}
|
||||
onSubmit={onSubmit}
|
||||
noValidate
|
||||
autoComplete={autocomplete}
|
||||
>
|
||||
{children}
|
||||
</form>
|
||||
</FormProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default Form;
|
||||
82
web/components/src/form/checkbox/CheckboxInput.tsx
Normal file
82
web/components/src/form/checkbox/CheckboxInput.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Controller, useFormContext } from "react-hook-form";
|
||||
|
||||
import createUseStyles from "../../theme/createUseStyles";
|
||||
import { cn } from "../../util/cn";
|
||||
import { Checkmark } from "@carbon/icons-react";
|
||||
|
||||
const useStyles = createUseStyles<{ isChecked?: boolean }>({
|
||||
buttonDisabled: {
|
||||
backgroundColor: ({ isChecked }) => isChecked === true && "var(--color-darkGrey)",
|
||||
},
|
||||
buttonError: {
|
||||
border: "2px solid var(--color-red) !important",
|
||||
},
|
||||
});
|
||||
|
||||
export default function CheckboxInput({
|
||||
children,
|
||||
className,
|
||||
name,
|
||||
label,
|
||||
error = false,
|
||||
}: React.PropsWithChildren<{
|
||||
className?: string;
|
||||
error?: boolean;
|
||||
label?: string | ReactNode;
|
||||
name: string;
|
||||
}>) {
|
||||
const { control, watch } = useFormContext();
|
||||
const isChecked = watch(name) as boolean;
|
||||
const classes = useStyles({ isChecked });
|
||||
|
||||
return (
|
||||
<Controller
|
||||
name={name}
|
||||
control={control}
|
||||
render={(props) => {
|
||||
const {
|
||||
field: { onChange, ...field },
|
||||
} = props;
|
||||
return (
|
||||
<div className={cn("flex", className)}>
|
||||
<input
|
||||
className="hidden absolute"
|
||||
data-testid={`checkbox-input-${name}`}
|
||||
type="text"
|
||||
aria-hidden
|
||||
readOnly
|
||||
{...field}
|
||||
/>
|
||||
{/** biome-ignore lint/a11y/useSemanticElements: <explanation> todo*/}
|
||||
<div
|
||||
className={cn("items-center bg-black border-white cursor-pointer flex justify-center w-l h-l min-w-l min-h-l outline-none", {
|
||||
[classes.buttonError]: error,
|
||||
[cn("border-white cursor-default", classes.buttonDisabled)]: field.disabled,
|
||||
})}
|
||||
onClick={() => {
|
||||
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions
|
||||
onChange(!field.value);
|
||||
}}
|
||||
onKeyUp={() => {
|
||||
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions
|
||||
onChange(!field.value);
|
||||
}}
|
||||
data-testid={`checkbox-${name}`}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
{isChecked ? <Checkmark /> : null}
|
||||
</div>
|
||||
<span className="word-break-break-word user-select-none pl-m font-lineHeight-l font-size-s text-white max-w-full">
|
||||
{[null, undefined].includes(label as null)
|
||||
? null
|
||||
: label}
|
||||
{children}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
73
web/components/src/form/select/SelectInput.tsx
Normal file
73
web/components/src/form/select/SelectInput.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import { TextUtil } from "@greatness/util";
|
||||
import { memo, useCallback } from "react";
|
||||
import { Controller, useFormContext } from "react-hook-form";
|
||||
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "../../ui/select";
|
||||
|
||||
import type { SelectInputValue } from "./types";
|
||||
|
||||
const SelectInput: React.FC<{
|
||||
data: SelectInputValue[];
|
||||
isLoading?: boolean;
|
||||
label: string;
|
||||
maxHeight?: number;
|
||||
name: string;
|
||||
popupIcon?: React.ReactNode;
|
||||
// @ts-expect-error check later
|
||||
}> = ({ name, label, data, isLoading = false, maxHeight = 250 }) => {
|
||||
const { setValue, watch, control } = useFormContext();
|
||||
const [currentValue] = watch([name]);
|
||||
|
||||
const onChange = useCallback(
|
||||
(newInputValue: string) => {
|
||||
setValue(name, newInputValue, {
|
||||
shouldDirty: true,
|
||||
shouldValidate: true,
|
||||
});
|
||||
},
|
||||
[name, setValue],
|
||||
);
|
||||
|
||||
return (
|
||||
<Controller
|
||||
control={control}
|
||||
name={name}
|
||||
render={() => {
|
||||
return (
|
||||
<Select
|
||||
value={currentValue}
|
||||
onValueChange={(newInputValue) => {
|
||||
onChange(newInputValue);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue
|
||||
placeholder={TextUtil.isEmpty(label) ? "" : label}
|
||||
>
|
||||
{/** biome-ignore lint/suspicious/noDoubleEquals: <explanation> todo*/}
|
||||
{data.find((a) => a.value == currentValue)?.label ?? ""}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{data
|
||||
.filter((a) => a.value !== "")
|
||||
.map((a) => (
|
||||
<SelectItem key={a.value as string} value={a.value as string}>
|
||||
{a.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(SelectInput);
|
||||
4
web/components/src/form/select/types.ts
Normal file
4
web/components/src/form/select/types.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export interface SelectInputValue {
|
||||
label: string;
|
||||
value: string | number | boolean;
|
||||
}
|
||||
84
web/components/src/form/text/TextInput.tsx
Normal file
84
web/components/src/form/text/TextInput.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import { TextUtil } from "@greatness/util";
|
||||
import { Controller, useFormContext } from "react-hook-form";
|
||||
|
||||
import createUseStyles from "../../theme/createUseStyles";
|
||||
import { cn } from "../../util/cn";
|
||||
|
||||
const useStyles = createUseStyles({
|
||||
textField: {
|
||||
"& > input": {
|
||||
"-moz-box-shadow": "none",
|
||||
"-webkit-box-shadow": "none",
|
||||
appearance: "none",
|
||||
backgroundImage: "none",
|
||||
boxShadow: "none",
|
||||
minHeight: 40,
|
||||
},
|
||||
"& > label": {
|
||||
left: 10,
|
||||
top: -5,
|
||||
whiteSpace: "nowrap",
|
||||
width: "min-content",
|
||||
},
|
||||
minHeight: 42,
|
||||
},
|
||||
});
|
||||
|
||||
export default function TextInput({
|
||||
className,
|
||||
label,
|
||||
name,
|
||||
type = "text",
|
||||
isDisabled = false,
|
||||
autoComplete,
|
||||
}: {
|
||||
autoComplete?: string;
|
||||
className?: string;
|
||||
isDisabled?: boolean;
|
||||
label?: string;
|
||||
name: string;
|
||||
type?: "text" | "password" | "email" | "textarea";
|
||||
}) {
|
||||
const classes = useStyles();
|
||||
const {
|
||||
watch,
|
||||
control,
|
||||
} = useFormContext();
|
||||
const currentValue = watch(name);
|
||||
|
||||
return (
|
||||
<Controller
|
||||
name={name}
|
||||
control={control}
|
||||
defaultValue={currentValue ?? ""}
|
||||
render={({ field }) => (
|
||||
<div className={cn("flex flex-col flex-1 relative justify-center", classes.textField, className)}>
|
||||
{type === "textarea" ? (
|
||||
<textarea
|
||||
id={name}
|
||||
{...field}
|
||||
disabled={isDisabled}
|
||||
className="border-white text-white outline-none py-xs px-s bg-black"
|
||||
rows={4}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
type={type}
|
||||
id={name}
|
||||
{...field}
|
||||
disabled={isDisabled}
|
||||
autoComplete={autoComplete}
|
||||
{...(name === "newPassword" && {
|
||||
autoComplete: "new-password",
|
||||
})}
|
||||
className="border-white text-white outline-none py-xs px-s bg-black"
|
||||
/>
|
||||
)}
|
||||
<label htmlFor={name} className="absolute word-break-keep-all bg-black font-size-xxs py-0 px-s text-white">
|
||||
{TextUtil.isEmpty(label) ? "" : label}
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
0
web/components/src/index.ts
Normal file
0
web/components/src/index.ts
Normal file
112
web/components/src/loading/LoadingPage.tsx
Normal file
112
web/components/src/loading/LoadingPage.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import { TextUtil } from "@greatness/util";
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
import createUseStyles from "../theme/createUseStyles";
|
||||
|
||||
import Spinner from "./Spinner";
|
||||
import { cn } from "../util/cn";
|
||||
|
||||
const useStyles = createUseStyles<{ containerHeight?: number | string }>({
|
||||
container: {
|
||||
height: ({ containerHeight }) =>
|
||||
containerHeight !== undefined
|
||||
? typeof containerHeight === "number"
|
||||
? `${containerHeight}px`
|
||||
: containerHeight
|
||||
: "100%",
|
||||
},
|
||||
});
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type ScrollIntoViewRef = React.RefObject<HTMLDivElement | null> | null;
|
||||
type ScrollIntoViewOptions = {
|
||||
behavior?: ScrollBehavior;
|
||||
block?: ScrollLogicalPosition;
|
||||
inline?: ScrollLogicalPosition;
|
||||
narrow?: boolean;
|
||||
} | null;
|
||||
type ScrollIntoViewFunction = (
|
||||
ref?: ScrollIntoViewRef,
|
||||
options?: ScrollIntoViewOptions,
|
||||
) => void;
|
||||
|
||||
const scrollIntoView: ScrollIntoViewFunction = (ref, options) => {
|
||||
if (!ref?.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
let scrollOptions = options;
|
||||
const isWide = window.innerWidth > 768;
|
||||
if (!scrollOptions || (options?.narrow === true && isWide)) {
|
||||
scrollOptions = {
|
||||
behavior: "smooth",
|
||||
};
|
||||
if (isWide) {
|
||||
scrollOptions.block = "center";
|
||||
}
|
||||
}
|
||||
ref.current.scrollIntoView(scrollOptions);
|
||||
};
|
||||
|
||||
export default function LoadingPage({
|
||||
className,
|
||||
isLoading = true,
|
||||
children,
|
||||
containerHeight,
|
||||
scroll,
|
||||
persist = false,
|
||||
label,
|
||||
}: {
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
containerHeight?: number | string;
|
||||
isLoading?: boolean;
|
||||
label?: string;
|
||||
persist?: boolean;
|
||||
scroll?: boolean;
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const classes = useStyles({ containerHeight });
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading) {
|
||||
if (scroll === true) {
|
||||
scrollIntoView(ref);
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isLoading, scroll]);
|
||||
|
||||
const isAnimationFinished = !isLoading;
|
||||
if (isAnimationFinished && !persist) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col items-center bg-white w-auto p-xs",
|
||||
classes.container,
|
||||
className,
|
||||
{
|
||||
["hidden"]: isAnimationFinished,
|
||||
}
|
||||
)}
|
||||
ref={ref}
|
||||
>
|
||||
<Spinner />
|
||||
{!TextUtil.isEmpty(label) && (
|
||||
<div className="pb-m pt-0 text-center word-break-break-word">
|
||||
<h4>{label!}</h4>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{persist && (
|
||||
<div className={isAnimationFinished ? "persist" : "hidden"}>
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
44
web/components/src/loading/Spinner.tsx
Normal file
44
web/components/src/loading/Spinner.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { cn } from "../util/cn";
|
||||
import createUseStyles from "../theme/createUseStyles";
|
||||
|
||||
export const spinnerAnimation = {
|
||||
"@keyframes spinner, @-webkit-keyframes spinner": {
|
||||
"0%": {
|
||||
"-webkit-transform": "rotate(0deg)",
|
||||
transform: "rotate(0deg)",
|
||||
},
|
||||
"100%": {
|
||||
"-webkit-transform": "rotate(360deg)",
|
||||
transform: "rotate(360deg)",
|
||||
},
|
||||
},
|
||||
};
|
||||
export const applySpinnerAnimation = {
|
||||
"-webkit-animation": "$spinner 1.1s ease-in-out infinite",
|
||||
animation: "$spinner 1.1s ease-in-out infinite",
|
||||
};
|
||||
|
||||
const useStyles = createUseStyles({
|
||||
...spinnerAnimation,
|
||||
loader: {
|
||||
MsTransform: "translateZ(0)",
|
||||
WebkitTransform: "translateZ(0)",
|
||||
border: "var(--spacing-s) solid var(--color-white)",
|
||||
borderLeftColor: "var(--color-darkGrey)",
|
||||
margin: "var(--spacing-xl) auto",
|
||||
position: "relative",
|
||||
textIndent: "-9999em",
|
||||
transform: "translateZ(0)",
|
||||
...applySpinnerAnimation,
|
||||
"&, &:after": {
|
||||
borderRadius: "50%",
|
||||
height: 80,
|
||||
width: 80,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export default function Spinner() {
|
||||
const classes = useStyles();
|
||||
return <div className={cn("font-size-xxxs", classes.loader)} />;
|
||||
}
|
||||
3
web/components/src/table/TableBody.tsx
Normal file
3
web/components/src/table/TableBody.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
export default function TableBody({ children }: React.PropsWithChildren) {
|
||||
return <div className="flex-1 flex flex-col">{children}</div>;
|
||||
};
|
||||
20
web/components/src/table/TableHead.tsx
Normal file
20
web/components/src/table/TableHead.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import createUseStyles from "../theme/createUseStyles";
|
||||
import { cn } from "../util/cn";
|
||||
|
||||
const useStyles = createUseStyles({
|
||||
tableHead: {
|
||||
"& > div": {
|
||||
"&:hover": {
|
||||
backgroundColor: "inherit !important",
|
||||
cursor: "initial !important",
|
||||
},
|
||||
borderBottom: "1px solid var(--color-grey)",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export default function TableHead({ children }: React.PropsWithChildren) {
|
||||
const classes = useStyles();
|
||||
|
||||
return <div className={cn("flex-1 flex", classes.tableHead)}>{children}</div>;
|
||||
}
|
||||
55
web/components/src/table/TableRow.tsx
Normal file
55
web/components/src/table/TableRow.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import createUseStyles from "../theme/createUseStyles";
|
||||
import { cn } from "../util/cn";
|
||||
|
||||
export const TABLE_ROW_HEIGHT = 35;
|
||||
|
||||
const useStyles = createUseStyles({
|
||||
tableRow: {
|
||||
"& > div": {
|
||||
"&:first-child": {
|
||||
flexGrow: [3, "!important"],
|
||||
},
|
||||
"&:last-child": {
|
||||
alignContent: "flex-end",
|
||||
},
|
||||
"&:nth-last-child(2)": {
|
||||
flexGrow: [1, "!important"],
|
||||
},
|
||||
alignContent: "center",
|
||||
display: "flex",
|
||||
flex: 1,
|
||||
flexFlow: "column",
|
||||
flexGrow: 2,
|
||||
justifyContent: "center",
|
||||
padding: "var(--spacing-xxs) 0",
|
||||
},
|
||||
"&:hover": {
|
||||
backgroundColor: "rgba(255, 255, 255, 0.15)",
|
||||
cursor: "pointer",
|
||||
},
|
||||
"&:not(:last-child)": {
|
||||
borderBottom: "1px solid var(--color-grey)",
|
||||
},
|
||||
height: TABLE_ROW_HEIGHT,
|
||||
},
|
||||
});
|
||||
|
||||
export default function TableRow({
|
||||
children,
|
||||
rowClassName,
|
||||
rowProps,
|
||||
}: React.PropsWithChildren<{
|
||||
rowClassName?: string;
|
||||
rowProps?: {
|
||||
onClick: React.MouseEventHandler<HTMLElement>;
|
||||
tabIndex: 0;
|
||||
};
|
||||
}>) {
|
||||
const classes = useStyles();
|
||||
|
||||
return (
|
||||
<div className={cn("flex-1 flex gap-m py-xxs px-s", classes.tableRow, rowClassName)} {...rowProps}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
14
web/components/src/table/TableWrapper.tsx
Normal file
14
web/components/src/table/TableWrapper.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { cn } from "../util/cn";
|
||||
|
||||
export default function TableWrapper({
|
||||
children,
|
||||
tableWrapperClassName,
|
||||
}: React.PropsWithChildren<{
|
||||
tableWrapperClassName?: string;
|
||||
}>) {
|
||||
return (
|
||||
<div className={cn("flex flex-col h-full w-full border-white", tableWrapperClassName)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
16
web/components/src/theme/createUseStyles.ts
Normal file
16
web/components/src/theme/createUseStyles.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { Styles } from "react-jss";
|
||||
import { createUseStyles as ogCreateUseStyles } from "react-jss";
|
||||
|
||||
import type commonTheme from "./index";
|
||||
|
||||
const createUseStyles = <Props>(
|
||||
styles:
|
||||
| Styles<string | number, Props, typeof commonTheme>
|
||||
| ((
|
||||
theme: typeof commonTheme,
|
||||
) => Styles<string | number, Props, undefined>),
|
||||
) => {
|
||||
return ogCreateUseStyles(styles);
|
||||
};
|
||||
|
||||
export default createUseStyles;
|
||||
91
web/components/src/theme/index.ts
Normal file
91
web/components/src/theme/index.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
const theme = {
|
||||
borderRadius: {
|
||||
/* eslint-disable sort-keys-fix/sort-keys-fix */
|
||||
s: 4,
|
||||
m: 8,
|
||||
l: 16,
|
||||
/* eslint-enable sort-keys-fix/sort-keys-fix */
|
||||
},
|
||||
color: {
|
||||
/* eslint-disable sort-keys-fix/sort-keys-fix */
|
||||
white: "rgba(255, 255, 255, 1)",
|
||||
whiteOpacity: "rgba(255, 255, 255, 0.3)",
|
||||
|
||||
black: "rgba(0, 0, 0, 1)",
|
||||
|
||||
grey: "rgba(238, 238, 238, 1)",
|
||||
darkGrey: "rgba(144, 144, 144, 1)",
|
||||
|
||||
red: "rgba(255, 0, 0, 1)",
|
||||
semiLightRed: "rgba(255, 0, 0, 0.75)",
|
||||
lightRed: "rgba(255, 0, 0, 0.5)",
|
||||
|
||||
lightGreen: "rgba(0, 255, 255, 0.75)",
|
||||
green: "rgba(0, 255, 0, 1)",
|
||||
darkGreen: "rgba(27, 120, 43, 1)",
|
||||
|
||||
warningOrange: "rgba(237, 111, 46, 0.8)",
|
||||
|
||||
yellow: "rgba(255, 255, 0, 0.8)",
|
||||
|
||||
blue: "rgba(0, 0, 255, 1)",
|
||||
/* eslint-enable sort-keys-fix/sort-keys-fix */
|
||||
},
|
||||
font: {
|
||||
letterSpacing: {
|
||||
/* eslint-disable sort-keys-fix/sort-keys-fix */
|
||||
s: "0.15px",
|
||||
m: "0.32px",
|
||||
l: "0.4px",
|
||||
/* eslint-enable sort-keys-fix/sort-keys-fix */
|
||||
},
|
||||
lineHeight: {
|
||||
/* eslint-disable sort-keys-fix/sort-keys-fix */
|
||||
s: 1.2,
|
||||
m: 1.4,
|
||||
l: 1.7,
|
||||
/* eslint-enable sort-keys-fix/sort-keys-fix */
|
||||
},
|
||||
size: {
|
||||
/* eslint-disable sort-keys-fix/sort-keys-fix */
|
||||
xxs: 12,
|
||||
xs: 14,
|
||||
s: 16,
|
||||
m: 18,
|
||||
l: 24,
|
||||
xl: 36,
|
||||
xxl: 48,
|
||||
/* eslint-enable sort-keys-fix/sort-keys-fix */
|
||||
},
|
||||
},
|
||||
media: {
|
||||
/* eslint-disable sort-keys-fix/sort-keys-fix */
|
||||
xs: "@media screen and (min-width: 396px)",
|
||||
sm: "@media screen and (min-width: 576px)",
|
||||
md: "@media screen and (min-width: 768px)",
|
||||
lg: "@media screen and (min-width: 992px)",
|
||||
xl: "@media screen and (min-width: 1200px)",
|
||||
xxl: "@media screen and (min-width: 1350px)",
|
||||
/* eslint-enable sort-keys-fix/sort-keys-fix */
|
||||
},
|
||||
sizes: {
|
||||
maxWidth: "@media screen and (min-width: 1440px)",
|
||||
maxWidthValue: 1440,
|
||||
},
|
||||
spacing: {
|
||||
/* eslint-disable sort-keys-fix/sort-keys-fix */
|
||||
xxs: 2,
|
||||
xs: 4,
|
||||
s: 8,
|
||||
m: 16,
|
||||
l: 24,
|
||||
xl: 32,
|
||||
xxl: 40,
|
||||
"3xl": 48,
|
||||
"4xl": 64,
|
||||
"5xl": 128,
|
||||
/* eslint-enable sort-keys-fix/sort-keys-fix */
|
||||
},
|
||||
};
|
||||
|
||||
export default theme;
|
||||
22
web/components/src/theme/scrollbar.ts
Normal file
22
web/components/src/theme/scrollbar.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
export const scrollbarStyles = {
|
||||
"&::-webkit-scrollbar": {
|
||||
background: "#E6E8EC",
|
||||
width: 8,
|
||||
},
|
||||
"&::-webkit-scrollbar-thumb": {
|
||||
"&:hover": {
|
||||
background: "#75787A",
|
||||
},
|
||||
background: "#C3C4C6",
|
||||
},
|
||||
};
|
||||
|
||||
export const horizontalScrollBar = {
|
||||
"&::-webkit-scrollbar": {
|
||||
background: "#E6E8EC",
|
||||
height: 8,
|
||||
},
|
||||
"&::-webkit-scrollbar-thumb": {
|
||||
backgroundColor: "#C3C4C6",
|
||||
},
|
||||
};
|
||||
198
web/components/src/ui/select.tsx
Normal file
198
web/components/src/ui/select.tsx
Normal file
@@ -0,0 +1,198 @@
|
||||
import * as SelectPrimitive from "@radix-ui/react-select";
|
||||
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
|
||||
import type * as React from "react";
|
||||
|
||||
import { cn } from "../util/cn";
|
||||
|
||||
function Select({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||
return <SelectPrimitive.Root data-slot="select" {...props} />;
|
||||
}
|
||||
|
||||
function SelectGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
|
||||
}
|
||||
|
||||
function SelectValue({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
|
||||
}
|
||||
|
||||
function SelectTrigger({
|
||||
className,
|
||||
size = "default",
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||
size?: "sm" | "default";
|
||||
}) {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"flex w-fit items-center justify-between gap-2 rounded-none border px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"!bg-black !text-white !border-white",
|
||||
"data-[placeholder]:!text-white",
|
||||
"hover:!bg-neutral-900 focus-visible:!border-white focus-visible:ring-white/30",
|
||||
"aria-invalid:!border-red aria-invalid:ring-red/20",
|
||||
"data-[size=default]:h-10 data-[size=sm]:h-8",
|
||||
"*:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2",
|
||||
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"dark:!bg-black dark:!text-white dark:!border-white dark:hover:!bg-neutral-900 data-[placeholder]:dark:!text-white focus-visible:dark:!border-white",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDownIcon className="size-4 !text-white opacity-100" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectContent({
|
||||
className,
|
||||
children,
|
||||
position = "popper",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
data-slot="select-content"
|
||||
className={cn(
|
||||
"relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-none border shadow-md",
|
||||
"!bg-black !text-white !border-white",
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className,
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||
return (
|
||||
<SelectPrimitive.Label
|
||||
data-slot="select-label"
|
||||
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default items-center gap-2 rounded-sm text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
"py-xs pl-m pr-xxl",
|
||||
"!bg-black !text-white",
|
||||
"focus:!bg-neutral-900 focus:!text-white",
|
||||
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
"[&_svg:not([class*='text-'])]:!text-white",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute right-2 flex size-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||
return (
|
||||
<SelectPrimitive.Separator
|
||||
data-slot="select-separator"
|
||||
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectScrollUpButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
data-slot="select-scroll-up-button"
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUpIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectScrollDownButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
data-slot="select-scroll-down-button"
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDownIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
};
|
||||
6
web/components/src/util/cn.ts
Normal file
6
web/components/src/util/cn.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { type ClassValue, clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
22
web/components/tsconfig.json
Normal file
22
web/components/tsconfig.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"extends": "../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"composite": true,
|
||||
"noEmit": false,
|
||||
"jsx": "react-jsx",
|
||||
"types": [
|
||||
"node",
|
||||
"react",
|
||||
"react-dom",
|
||||
"react-router-dom"
|
||||
],
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx"],
|
||||
"exclude": ["node_modules", "dist", "src/**/*.test.ts"]
|
||||
}
|
||||
2
web/frontend/.env.example
Normal file
2
web/frontend/.env.example
Normal file
@@ -0,0 +1,2 @@
|
||||
VITE_API_PATH=http://localhost:8555/api
|
||||
VITE_NEW_API_PATH=http://localhost:8555/api
|
||||
21
web/frontend/components.json
Normal file
21
web/frontend/components.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.js",
|
||||
"css": "src/theme/styles.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"iconLibrary": "lucide"
|
||||
}
|
||||
15
web/frontend/index.html
Normal file
15
web/frontend/index.html
Normal file
@@ -0,0 +1,15 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" class="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="theme-color" content="#1B8839">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="version" content="1">
|
||||
<link rel="icon" type="image/x-icon" href="../static/favicon.ico">
|
||||
<title>Biostacker</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root" />
|
||||
<script type="module" src="/src/index.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
81
web/frontend/package.json
Executable file
81
web/frontend/package.json
Executable file
@@ -0,0 +1,81 @@
|
||||
{
|
||||
"name": "biostacker-frontend",
|
||||
"version": "1.0.0",
|
||||
"license": "UNLICENSED",
|
||||
"author": "k4rli",
|
||||
"private": true,
|
||||
"main": "vite.config.ts",
|
||||
"engines": {
|
||||
"node": ">=24"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite --no-open",
|
||||
"build": "tsc && vite build",
|
||||
"build:analyze": "ANALYZE=true vite build",
|
||||
"clean": "rimraf dist",
|
||||
"lint": "biome lint src",
|
||||
"lint:fix": "biome lint src --write",
|
||||
"format": "biome format src",
|
||||
"format:check": "biome format src --check",
|
||||
"check": "biome check src",
|
||||
"depcheck": "depcheck",
|
||||
"find-deadcode": "ts-prune"
|
||||
},
|
||||
"dependencies": {
|
||||
"@carbon/icons-react": "11.63.0",
|
||||
"@greatness/components": "workspace:*",
|
||||
"@greatness/util": "workspace:*",
|
||||
"@floating-ui/react": "0.27.13",
|
||||
"@hookform/resolvers": "5.2.0",
|
||||
"@radix-ui/react-select": "2.2.5",
|
||||
"@tailwindcss/vite": "4.1.11",
|
||||
"@tanstack/react-query": "5.83.0",
|
||||
"@tanstack/react-query-devtools": "5.83.0",
|
||||
"@types/i18n-js": "3.8.9",
|
||||
"@types/js-cookie": "3.0.6",
|
||||
"@types/node": "24.1.0",
|
||||
"@types/react": "19.1.8",
|
||||
"@types/react-dom": "19.1.6",
|
||||
"awesome-debounce-promise": "2.1.0",
|
||||
"class-variance-authority": "0.7.1",
|
||||
"clsx": "2.1.1",
|
||||
"i18n-js": "3.8.0",
|
||||
"js-cookie": "3.0.5",
|
||||
"lucide-react": "0.525.0",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"react-error-boundary": "6.0.0",
|
||||
"react-hook-form": "7.61.1",
|
||||
"react-jss": "10.10.0",
|
||||
"react-router-dom": "7.7.1",
|
||||
"react-select": "5.10.2",
|
||||
"recharts": "3.1.0",
|
||||
"tailwind-merge": "3.3.1",
|
||||
"tailwindcss": "4.1.11",
|
||||
"vis-timeline": "8.1.2",
|
||||
"yup": "1.6.1",
|
||||
"zod": "3.25.74"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@js-temporal/polyfill": "0.5.1",
|
||||
"@types/jest": "30.0.0",
|
||||
"@vitejs/plugin-react-swc": "3.11.0",
|
||||
"depcheck": "1.4.7",
|
||||
"react-loading-skeleton": "3.5.0",
|
||||
"rimraf": "6.0.1",
|
||||
"rollup-plugin-visualizer": "6.0.3",
|
||||
"sass": "1.89.2",
|
||||
"ts-prune": "0.10.3",
|
||||
"tw-animate-css": "1.3.6",
|
||||
"typescript": "5.8.3",
|
||||
"vite": "7.0.6",
|
||||
"vite-tsconfig-paths": "5.1.4"
|
||||
},
|
||||
"browserslist": [
|
||||
">1%",
|
||||
"last 4 versions",
|
||||
"not ie <= 11",
|
||||
"not op_mini all",
|
||||
"not dead"
|
||||
]
|
||||
}
|
||||
4
web/frontend/src/@types/declarations.d.ts
vendored
Normal file
4
web/frontend/src/@types/declarations.d.ts
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
declare module "*.scss" {
|
||||
const content: { [className: string]: string };
|
||||
export = content;
|
||||
}
|
||||
10
web/frontend/src/@types/env.d.ts
vendored
Normal file
10
web/frontend/src/@types/env.d.ts
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_PATH: string;
|
||||
// more env variables...
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
60
web/frontend/src/api/common.types.ts
Normal file
60
web/frontend/src/api/common.types.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
export enum ContentType {
|
||||
APPLICATION_JSON = "application/json",
|
||||
URL_ENCODED = "application/x-www-form-urlencoded; charset=UTF-8",
|
||||
}
|
||||
|
||||
export enum RequestMethod {
|
||||
DELETE = "DELETE",
|
||||
GET = "GET",
|
||||
POST = "POST",
|
||||
PUT = "PUT",
|
||||
}
|
||||
|
||||
export enum RequestHeader {
|
||||
ACCEPT = "accept",
|
||||
CONTENT_TYPE = "content-Type",
|
||||
REFERRER = "referrer",
|
||||
SET_COOKIE = "set-cookie",
|
||||
X_JWT = "x-jwt",
|
||||
}
|
||||
|
||||
export enum ResponseStatus {
|
||||
OK = 200,
|
||||
BAD_REQUEST = 400,
|
||||
UNAUTHORIZED = 401,
|
||||
FORBIDDEN = 403,
|
||||
METHOD_NOT_ALLOWED = 405,
|
||||
UNPROCESSABLE_CONTENT = 422,
|
||||
INTERNAL_SERVER_ERROR = 500,
|
||||
}
|
||||
|
||||
export enum HeaderAccept {
|
||||
IMAGE_PNG = "image/png",
|
||||
}
|
||||
|
||||
export enum MimeType {
|
||||
DOCUMENT_CSV = "text/csv",
|
||||
DOCUMENT_MS_EXCEL = "application/vnd.ms-excel",
|
||||
DOCUMENT_MS_EXCEL_OPENXML = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
DOCUMENT_MS_POWERPOINT = "application/vnd.ms-powerpoint",
|
||||
DOCUMENT_MS_POWERPOINT_OPENXML = "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||
DOCUMENT_MS_WORD = "application/msword",
|
||||
DOCUMENT_MS_WORD_OPENXML = "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
DOCUMENT_OPEN_PRESENTATION = "application/vnd.oasis.opendocument.presentation",
|
||||
DOCUMENT_OPEN_SPREADSHEET = "application/vnd.oasis.opendocument.spreadsheet",
|
||||
DOCUMENT_OPEN_TEXT = "application/vnd.oasis.opendocument.text",
|
||||
DOCUMENT_PDF = "application/pdf",
|
||||
DOCUMENT_PLAIN_TEXT = "text/plain",
|
||||
DOCUMENT_RTF = "application/rtf",
|
||||
IMAGE_AVIF = "image/avif",
|
||||
IMAGE_BMP = "image/bmp",
|
||||
IMAGE_GIF = "image/gif",
|
||||
IMAGE_HEIC = "image/heic",
|
||||
IMAGE_HEIF = "image/heif",
|
||||
IMAGE_JPEG = "image/jpeg",
|
||||
IMAGE_JPG = "image/jpg",
|
||||
IMAGE_PNG = "image/png",
|
||||
IMAGE_SVG = "image/svg+xml",
|
||||
IMAGE_TIFF = "image/tiff",
|
||||
IMAGE_WEBP = "image/webp",
|
||||
}
|
||||
73
web/frontend/src/api/controller/todo.ts
Normal file
73
web/frontend/src/api/controller/todo.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { RequestMethod } from '../common.types';
|
||||
import { request } from '../request';
|
||||
|
||||
// Types
|
||||
export interface Todo {
|
||||
id: string;
|
||||
userId: string;
|
||||
title: string;
|
||||
description: string;
|
||||
color: string;
|
||||
isActive: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface TodoWithStats extends Todo {
|
||||
currentStreak: number;
|
||||
longestStreak: number;
|
||||
totalCompletions: number;
|
||||
lastCompletedAt?: string;
|
||||
completedToday: boolean;
|
||||
}
|
||||
|
||||
export interface TodoCompletion {
|
||||
id: string;
|
||||
todoId: string;
|
||||
userId: string;
|
||||
completedAt: string;
|
||||
description: string;
|
||||
todo: Todo;
|
||||
}
|
||||
|
||||
export interface DailyTodoSummary {
|
||||
date: string;
|
||||
todos: TodoWithStats[];
|
||||
completedCount: number;
|
||||
totalCount: number;
|
||||
}
|
||||
|
||||
export interface CreateTodoRequest {
|
||||
title: string;
|
||||
description: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export interface UpdateTodoRequest {
|
||||
title: string;
|
||||
description: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export interface CompleteTodoRequest {
|
||||
description: string;
|
||||
}
|
||||
|
||||
// API Functions
|
||||
export const todoApi = {
|
||||
// Create a new todo
|
||||
createTodo: (data: CreateTodoRequest) =>
|
||||
request<Todo>(RequestMethod.POST, '/todo/create', data),
|
||||
|
||||
// Update a todo
|
||||
updateTodo: (id: string, data: UpdateTodoRequest) =>
|
||||
request<Todo>(RequestMethod.PUT, `/todo/${id}`, data),
|
||||
|
||||
// Delete a todo
|
||||
deleteTodo: (id: string) =>
|
||||
request<{ message: string }>(RequestMethod.DELETE, `/todo/${id}`),
|
||||
|
||||
// Complete a todo
|
||||
completeTodo: (id: string, data: CompleteTodoRequest) =>
|
||||
request<{ message: string }>(RequestMethod.POST, `/todo/${id}/complete`, data),
|
||||
};
|
||||
30
web/frontend/src/api/controller/user.ts
Normal file
30
web/frontend/src/api/controller/user.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
|
||||
import { RequestMethod } from "../common.types";
|
||||
import { request } from "../request";
|
||||
|
||||
export interface IUserLoginRequest {
|
||||
email: string;
|
||||
password: string;
|
||||
rememberMe?: boolean;
|
||||
}
|
||||
|
||||
export interface IUserLoginResponse {
|
||||
token: string;
|
||||
}
|
||||
|
||||
export interface IUserMeResponse {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export const UserApiPath = {
|
||||
login: "/user/login",
|
||||
me: "/user/me",
|
||||
};
|
||||
|
||||
export const User = {
|
||||
login: async (data: IUserLoginRequest) =>
|
||||
request<IUserLoginResponse>(RequestMethod.POST, UserApiPath.login, data),
|
||||
me: async () => request<IUserMeResponse>(RequestMethod.GET, UserApiPath.me),
|
||||
};
|
||||
120
web/frontend/src/api/request.ts
Normal file
120
web/frontend/src/api/request.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import type {
|
||||
IErrorResponse,
|
||||
RequestBody,
|
||||
RequestResponse,
|
||||
} from "./request.types";
|
||||
import { ContentType, RequestHeader, RequestMethod } from "./common.types";
|
||||
import { TextUtil } from "@greatness/util";
|
||||
|
||||
import { NavigationPath } from "@/constants/navigation";
|
||||
import JWTUtil from "@/util/JWTUtil";
|
||||
|
||||
const GOLANG_API_PATHS = [
|
||||
"/admin/",
|
||||
"/crime-user-service/millionaires-questions",
|
||||
"/services-list",
|
||||
];
|
||||
|
||||
export class RequestHelper {
|
||||
public static async handleFetch(
|
||||
pathname: string,
|
||||
requestOptions?: RequestInit,
|
||||
): Promise<Response> {
|
||||
return fetch(RequestHelper.getApiPath(pathname), {
|
||||
...requestOptions,
|
||||
headers: {
|
||||
[RequestHeader.CONTENT_TYPE]: ContentType.APPLICATION_JSON,
|
||||
...RequestHelper.getAuthenticationHeaders(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
public static getAuthenticationHeaders() {
|
||||
const headers: Record<string, string> = {};
|
||||
const { accessToken } = JWTUtil.get();
|
||||
if (!TextUtil.isEmpty(accessToken)) {
|
||||
headers[RequestHeader.X_JWT] = accessToken!;
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
public static getApiPath(pathname: string, apiBasePath?: string): string {
|
||||
const apiPath = GOLANG_API_PATHS.some((p) => pathname.includes(p))
|
||||
? import.meta.env.VITE_NEW_API_PATH
|
||||
: import.meta.env.VITE_API_PATH;
|
||||
return `${apiBasePath ?? apiPath}${pathname}`;
|
||||
}
|
||||
|
||||
public static redirectToLoginPage(): void {
|
||||
JWTUtil.unset();
|
||||
|
||||
const loginPath = NavigationPath.Login;
|
||||
if (window.location.pathname !== loginPath) {
|
||||
window.location.pathname = loginPath;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const queryFetcher = async <T>({
|
||||
queryKey,
|
||||
}: {
|
||||
queryKey: readonly unknown[];
|
||||
}): Promise<T> =>
|
||||
RequestHelper.handleFetch(queryKey[0] as string).then(async (res) => res.json());
|
||||
|
||||
export const request = async <T extends object | undefined>(
|
||||
method: RequestMethod,
|
||||
pathname: string,
|
||||
body?: RequestBody,
|
||||
): RequestResponse<T> => {
|
||||
let response = null;
|
||||
let responseData: T | null = null;
|
||||
try {
|
||||
const isFormData = body instanceof FormData;
|
||||
response = await fetch(RequestHelper.getApiPath(pathname), {
|
||||
headers: {
|
||||
...(!isFormData && {
|
||||
[RequestHeader.CONTENT_TYPE]: ContentType.APPLICATION_JSON,
|
||||
}),
|
||||
...RequestHelper.getAuthenticationHeaders(),
|
||||
},
|
||||
method,
|
||||
...([RequestMethod.POST, RequestMethod.PUT].includes(method) && {
|
||||
body: isFormData ? body : JSON.stringify(body),
|
||||
}),
|
||||
});
|
||||
try {
|
||||
responseData = (await response.json()) as T;
|
||||
} catch {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn("No response data", responseData);
|
||||
}
|
||||
if (
|
||||
response.status === 401 &&
|
||||
!window.location.pathname.startsWith(NavigationPath.Login)
|
||||
) {
|
||||
RequestHelper.redirectToLoginPage();
|
||||
throw new Error();
|
||||
}
|
||||
return {
|
||||
isResponseOk: true,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
response: responseData as any,
|
||||
responseStatus: response.status,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Request error", error);
|
||||
return {
|
||||
isResponseOk: false,
|
||||
response: (() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
if (responseData !== null) {
|
||||
return responseData as IErrorResponse;
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return "" as unknown as IErrorResponse;
|
||||
})(),
|
||||
responseStatus: null,
|
||||
};
|
||||
}
|
||||
};
|
||||
40
web/frontend/src/api/request.types.ts
Normal file
40
web/frontend/src/api/request.types.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
export interface IErrorResponse {
|
||||
details?: Record<
|
||||
string,
|
||||
{
|
||||
message?: string;
|
||||
}
|
||||
>;
|
||||
fields?: {
|
||||
field: string;
|
||||
message: string;
|
||||
}[];
|
||||
message: string;
|
||||
}
|
||||
export type SuccessResponseParameter = object | undefined;
|
||||
export type ErrorResponseParameter = string | undefined;
|
||||
export type SuccessResponse<T extends SuccessResponseParameter = undefined> = T;
|
||||
export type ControllerResponse<
|
||||
T extends SuccessResponseParameter = SuccessResponseParameter,
|
||||
> = IErrorResponse | SuccessResponse<T>;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export type RequestBody = FormData | Record<string, any>;
|
||||
|
||||
export interface IResponseToken {
|
||||
authToken: string;
|
||||
}
|
||||
export type RequestResponse<
|
||||
T extends SuccessResponseParameter = SuccessResponseParameter,
|
||||
> = Promise<
|
||||
| {
|
||||
isResponseOk: false;
|
||||
response: IErrorResponse;
|
||||
responseStatus: number | null;
|
||||
}
|
||||
| {
|
||||
isResponseOk: true;
|
||||
response: SuccessResponse<T>;
|
||||
responseStatus: number | null;
|
||||
}
|
||||
>;
|
||||
5
web/frontend/src/api/types.ts
Normal file
5
web/frontend/src/api/types.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export type APIFieldError = {
|
||||
field?: string;
|
||||
message: string;
|
||||
messageCode?: string;
|
||||
};
|
||||
24
web/frontend/src/api/useQuery.ts
Normal file
24
web/frontend/src/api/useQuery.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { useQuery as useQueryTanstack } from "@tanstack/react-query";
|
||||
import { queryFetcher } from "./request";
|
||||
|
||||
export const REFETCH_INTERVAL_XS = 1_000;
|
||||
export const REFETCH_INTERVAL_S = 5_000;
|
||||
export const REFETCH_INTERVAL_L = 120_000;
|
||||
|
||||
export const useQuery = <T>({
|
||||
queryKey,
|
||||
refetchInterval = REFETCH_INTERVAL_S,
|
||||
isEnabled = true,
|
||||
}: {
|
||||
queryKey: string;
|
||||
refetchInterval?: number;
|
||||
isEnabled?: boolean;
|
||||
}) => {
|
||||
return useQueryTanstack({
|
||||
queryKey: [queryKey],
|
||||
queryFn: queryFetcher<T>,
|
||||
refetchOnWindowFocus: true,
|
||||
refetchInterval,
|
||||
enabled: isEnabled,
|
||||
});
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user