commit 0806865287367a29b222fed6d97b9183f26e5e45 Author: k4rli Date: Mon Nov 3 12:24:01 2025 +0200 updates diff --git a/server/.env.example b/server/.env.example new file mode 100644 index 0000000..d062aac --- /dev/null +++ b/server/.env.example @@ -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== diff --git a/server/.gitignore b/server/.gitignore new file mode 100644 index 0000000..29d9a17 --- /dev/null +++ b/server/.gitignore @@ -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/ diff --git a/server/Dockerfile b/server/Dockerfile new file mode 100644 index 0000000..01a4663 --- /dev/null +++ b/server/Dockerfile @@ -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"] diff --git a/server/Makefile b/server/Makefile new file mode 100644 index 0000000..2abbe37 --- /dev/null +++ b/server/Makefile @@ -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 diff --git a/server/README.md b/server/README.md new file mode 100644 index 0000000..3a4bf15 --- /dev/null +++ b/server/README.md @@ -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 +``` diff --git a/server/api b/server/api new file mode 100755 index 0000000..0ae22b5 Binary files /dev/null and b/server/api differ diff --git a/server/cmd/api/main.go b/server/cmd/api/main.go new file mode 100644 index 0000000..6dee597 --- /dev/null +++ b/server/cmd/api/main.go @@ -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{}, + ) +} diff --git a/server/compose.yaml b/server/compose.yaml new file mode 100644 index 0000000..0a6c044 --- /dev/null +++ b/server/compose.yaml @@ -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: diff --git a/server/go.mod b/server/go.mod new file mode 100644 index 0000000..b1ab82c --- /dev/null +++ b/server/go.mod @@ -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 +) diff --git a/server/go.sum b/server/go.sum new file mode 100644 index 0000000..500502a --- /dev/null +++ b/server/go.sum @@ -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= diff --git a/server/internal/config/config.go b/server/internal/config/config.go new file mode 100644 index 0000000..94bb45b --- /dev/null +++ b/server/internal/config/config.go @@ -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)) + } +} diff --git a/server/internal/database/db.go b/server/internal/database/db.go new file mode 100644 index 0000000..3ab833c --- /dev/null +++ b/server/internal/database/db.go @@ -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 +} diff --git a/server/internal/database/postgresql.go b/server/internal/database/postgresql.go new file mode 100644 index 0000000..654d118 --- /dev/null +++ b/server/internal/database/postgresql.go @@ -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 +} diff --git a/server/internal/database/redis.go b/server/internal/database/redis.go new file mode 100644 index 0000000..35fadf1 --- /dev/null +++ b/server/internal/database/redis.go @@ -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 +} diff --git a/server/internal/email/sender.go b/server/internal/email/sender.go new file mode 100644 index 0000000..682035c --- /dev/null +++ b/server/internal/email/sender.go @@ -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 +} diff --git a/server/internal/email/templates/base.html b/server/internal/email/templates/base.html new file mode 100644 index 0000000..62990f8 --- /dev/null +++ b/server/internal/email/templates/base.html @@ -0,0 +1,41 @@ + + + + + {{.Subject}} + + + +
+

{{.Title}}

+
+
{{.Content}}
+ + + diff --git a/server/internal/handlers/category_handler.go b/server/internal/handlers/category_handler.go new file mode 100644 index 0000000..632b27f --- /dev/null +++ b/server/internal/handlers/category_handler.go @@ -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) +} diff --git a/server/internal/handlers/daily_overview_handler.go b/server/internal/handlers/daily_overview_handler.go new file mode 100644 index 0000000..12683f0 --- /dev/null +++ b/server/internal/handlers/daily_overview_handler.go @@ -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), + }) +} diff --git a/server/internal/handlers/nutrient_handler.go b/server/internal/handlers/nutrient_handler.go new file mode 100644 index 0000000..627563e --- /dev/null +++ b/server/internal/handlers/nutrient_handler.go @@ -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) +} diff --git a/server/internal/handlers/supplement_handler.go b/server/internal/handlers/supplement_handler.go new file mode 100644 index 0000000..743ea5c --- /dev/null +++ b/server/internal/handlers/supplement_handler.go @@ -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) +} diff --git a/server/internal/handlers/todo_handler.go b/server/internal/handlers/todo_handler.go new file mode 100644 index 0000000..20348cf --- /dev/null +++ b/server/internal/handlers/todo_handler.go @@ -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 +} + diff --git a/server/internal/handlers/user_handler.go b/server/internal/handlers/user_handler.go new file mode 100644 index 0000000..09e9e59 --- /dev/null +++ b/server/internal/handlers/user_handler.go @@ -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"}) +} diff --git a/server/internal/middleware/auth.go b/server/internal/middleware/auth.go new file mode 100644 index 0000000..57d3996 --- /dev/null +++ b/server/internal/middleware/auth.go @@ -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[:]) +} diff --git a/server/internal/middleware/cors.go b/server/internal/middleware/cors.go new file mode 100644 index 0000000..d36f9a3 --- /dev/null +++ b/server/internal/middleware/cors.go @@ -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() + } +} diff --git a/server/internal/middleware/logger.go b/server/internal/middleware/logger.go new file mode 100644 index 0000000..ec68762 --- /dev/null +++ b/server/internal/middleware/logger.go @@ -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), + ) + } +} diff --git a/server/internal/models/base.go b/server/internal/models/base.go new file mode 100644 index 0000000..1976afc --- /dev/null +++ b/server/internal/models/base.go @@ -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 +} diff --git a/server/internal/models/category.go b/server/internal/models/category.go new file mode 100644 index 0000000..c2293df --- /dev/null +++ b/server/internal/models/category.go @@ -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"` +} diff --git a/server/internal/models/nutrient.go b/server/internal/models/nutrient.go new file mode 100644 index 0000000..6383a06 --- /dev/null +++ b/server/internal/models/nutrient.go @@ -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;"` +} diff --git a/server/internal/models/nutrient_category.go b/server/internal/models/nutrient_category.go new file mode 100644 index 0000000..0906b44 --- /dev/null +++ b/server/internal/models/nutrient_category.go @@ -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"` +} diff --git a/server/internal/models/supplement.go b/server/internal/models/supplement.go new file mode 100644 index 0000000..4a2a84d --- /dev/null +++ b/server/internal/models/supplement.go @@ -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"` +} diff --git a/server/internal/models/supplement_nutrient.go b/server/internal/models/supplement_nutrient.go new file mode 100644 index 0000000..9175a3d --- /dev/null +++ b/server/internal/models/supplement_nutrient.go @@ -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"` +} diff --git a/server/internal/models/todo.go b/server/internal/models/todo.go new file mode 100644 index 0000000..f950236 --- /dev/null +++ b/server/internal/models/todo.go @@ -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"` +} diff --git a/server/internal/models/ulid.go b/server/internal/models/ulid.go new file mode 100644 index 0000000..7fcd96b --- /dev/null +++ b/server/internal/models/ulid.go @@ -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{} +} diff --git a/server/internal/models/user.go b/server/internal/models/user.go new file mode 100755 index 0000000..ab81eae --- /dev/null +++ b/server/internal/models/user.go @@ -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"` +} diff --git a/server/internal/queue/email_service.go b/server/internal/queue/email_service.go new file mode 100644 index 0000000..cbbe43a --- /dev/null +++ b/server/internal/queue/email_service.go @@ -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 +} diff --git a/server/internal/queue/worker.go b/server/internal/queue/worker.go new file mode 100644 index 0000000..f936615 --- /dev/null +++ b/server/internal/queue/worker.go @@ -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 +} diff --git a/server/internal/repository/category_repository.go b/server/internal/repository/category_repository.go new file mode 100644 index 0000000..39cf0b6 --- /dev/null +++ b/server/internal/repository/category_repository.go @@ -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 +} diff --git a/server/internal/repository/daily_overview_repository.go b/server/internal/repository/daily_overview_repository.go new file mode 100644 index 0000000..1d71605 --- /dev/null +++ b/server/internal/repository/daily_overview_repository.go @@ -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 +} diff --git a/server/internal/repository/nutrient_repository.go b/server/internal/repository/nutrient_repository.go new file mode 100644 index 0000000..54f97ee --- /dev/null +++ b/server/internal/repository/nutrient_repository.go @@ -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 +} diff --git a/server/internal/repository/supplement_repository.go b/server/internal/repository/supplement_repository.go new file mode 100644 index 0000000..3ff7107 --- /dev/null +++ b/server/internal/repository/supplement_repository.go @@ -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 +} diff --git a/server/internal/repository/todo_repository.go b/server/internal/repository/todo_repository.go new file mode 100644 index 0000000..75cf5f6 --- /dev/null +++ b/server/internal/repository/todo_repository.go @@ -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 +} diff --git a/server/internal/repository/user_repository.go b/server/internal/repository/user_repository.go new file mode 100644 index 0000000..3ae3ce2 --- /dev/null +++ b/server/internal/repository/user_repository.go @@ -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 +} diff --git a/server/internal/routes/routes.go b/server/internal/routes/routes.go new file mode 100644 index 0000000..a5b0f1c --- /dev/null +++ b/server/internal/routes/routes.go @@ -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 +} diff --git a/server/internal/server/server.go b/server/internal/server/server.go new file mode 100644 index 0000000..327ec9d --- /dev/null +++ b/server/internal/server/server.go @@ -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) +} diff --git a/server/internal/service/category_service.go b/server/internal/service/category_service.go new file mode 100644 index 0000000..3c73ad7 --- /dev/null +++ b/server/internal/service/category_service.go @@ -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) +} diff --git a/server/internal/service/daily_overview_service.go b/server/internal/service/daily_overview_service.go new file mode 100644 index 0000000..f7ca1b3 --- /dev/null +++ b/server/internal/service/daily_overview_service.go @@ -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...) +} diff --git a/server/internal/service/nutrient_service.go b/server/internal/service/nutrient_service.go new file mode 100644 index 0000000..557f383 --- /dev/null +++ b/server/internal/service/nutrient_service.go @@ -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) +} diff --git a/server/internal/service/supplement_service.go b/server/internal/service/supplement_service.go new file mode 100644 index 0000000..5a04d4a --- /dev/null +++ b/server/internal/service/supplement_service.go @@ -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 +} diff --git a/server/internal/service/todo_service.go b/server/internal/service/todo_service.go new file mode 100644 index 0000000..33510d5 --- /dev/null +++ b/server/internal/service/todo_service.go @@ -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 +} + diff --git a/server/internal/service/user_service.go b/server/internal/service/user_service.go new file mode 100644 index 0000000..abf59bc --- /dev/null +++ b/server/internal/service/user_service.go @@ -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 +} diff --git a/server/pkg/logger/logger.go b/server/pkg/logger/logger.go new file mode 100644 index 0000000..7fb5442 --- /dev/null +++ b/server/pkg/logger/logger.go @@ -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...) +} diff --git a/server/pkg/validator/validator.go b/server/pkg/validator/validator.go new file mode 100644 index 0000000..c7c2dfd --- /dev/null +++ b/server/pkg/validator/validator.go @@ -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" + } +} diff --git a/server/scripts/migration.go b/server/scripts/migration.go new file mode 100644 index 0000000..9b9f726 --- /dev/null +++ b/server/scripts/migration.go @@ -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!") +} diff --git a/web/.gitignore b/web/.gitignore new file mode 100644 index 0000000..64c5566 --- /dev/null +++ b/web/.gitignore @@ -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 diff --git a/web/biome.json b/web/biome.json new file mode 100644 index 0000000..af4c000 --- /dev/null +++ b/web/biome.json @@ -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" + } + } +} diff --git a/web/components/package.json b/web/components/package.json new file mode 100644 index 0000000..eaf1bd2 --- /dev/null +++ b/web/components/package.json @@ -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" + } +} \ No newline at end of file diff --git a/web/components/src/AlertProvider.tsx b/web/components/src/AlertProvider.tsx new file mode 100644 index 0000000..48d1bdd --- /dev/null +++ b/web/components/src/AlertProvider.tsx @@ -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({ + addAlert: noop, + alerts: [], +}); + +export const useAlert = (): IAlertState => useContext(AlertContext); + +export default function AlertProvider({ + children, +}: React.PropsWithChildren) { + const [alerts, setAlerts] = useState([]); + + 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 ( + + {children} +
+ {alerts.map((alert, idx) => ( +
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", + })} + > +
+ {alert.severity === "error" ? ( + + ) : ( + + )} +
+
+ {alert.title && ( + {alert.title} + )} + {alert.message} +
+ {/*
+ +
*/} +
+ ))} +
+
+ ); +} diff --git a/web/components/src/Button.tsx b/web/components/src/Button.tsx new file mode 100644 index 0000000..2398ee3 --- /dev/null +++ b/web/components/src/Button.tsx @@ -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, + ) => void | Promise; + prefix?: React.ReactNode; + size?: ButtonSize; + type?: React.ButtonHTMLAttributes["type"]; + style?: React.CSSProperties; +} & Partial, "style" | "onMouseEnter" | "onMouseLeave">>>) { + const classes = useStyles({ + color, + disabled: isDisabled, + isLoading, + size, + }); + + const onClickWrapper = useCallback( + (e: React.MouseEvent) => { + if (isDisabled) { + return; + } + if (onClick) { + void onClick(e); + } + }, + [isDisabled, onClick], + ); + + return ( + + ); +}; diff --git a/web/components/src/Collapsible.tsx b/web/components/src/Collapsible.tsx new file mode 100644 index 0000000..4a09453 --- /dev/null +++ b/web/components/src/Collapsible.tsx @@ -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 ( + <> + + + {!isCollapsed && children} + + + ); +}; diff --git a/web/components/src/ComponentErrorBoundary.tsx b/web/components/src/ComponentErrorBoundary.tsx new file mode 100644 index 0000000..92396e7 --- /dev/null +++ b/web/components/src/ComponentErrorBoundary.tsx @@ -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 ( +
+

Something went wrong!

+

Please try again later.

+
+ ); +} + +export default function ComponentErrorBoundary({ + children, + onError, +}: React.PropsWithChildren<{ + onError?: (error: Error) => void; +}>) { + return ( + }> + {children} + + ); +} diff --git a/web/components/src/LayoutWrapper.tsx b/web/components/src/LayoutWrapper.tsx new file mode 100644 index 0000000..b07ada0 --- /dev/null +++ b/web/components/src/LayoutWrapper.tsx @@ -0,0 +1,8 @@ +import { useOutlet } from "react-router-dom"; + +import ComponentErrorBoundary from "./ComponentErrorBoundary"; + +export default function LayoutWrapper() { + const outlet = useOutlet(); + return {outlet}; +} diff --git a/web/components/src/Modal.tsx b/web/components/src/Modal.tsx new file mode 100644 index 0000000..7a40778 --- /dev/null +++ b/web/components/src/Modal.tsx @@ -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(null); + + useEffect(() => { + if (isOpen) { + ref.current?.showModal(); + } else { + ref.current?.close(); + } + }, [isOpen]); + + return ( + + {children} + + ); +} diff --git a/web/components/src/StatusIndicator.tsx b/web/components/src/StatusIndicator.tsx new file mode 100644 index 0000000..25f6569 --- /dev/null +++ b/web/components/src/StatusIndicator.tsx @@ -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 ( +
+ ); +} diff --git a/web/components/src/Tooltip.tsx b/web/components/src/Tooltip.tsx new file mode 100644 index 0000000..d7ab631 --- /dev/null +++ b/web/components/src/Tooltip.tsx @@ -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 ( + <> +
+ {children} +
+ {isOpen && ( +
+ {content} +
+ )} + + ); +}; diff --git a/web/components/src/confirm/ConfirmContext.ts b/web/components/src/confirm/ConfirmContext.ts new file mode 100644 index 0000000..a416ea5 --- /dev/null +++ b/web/components/src/confirm/ConfirmContext.ts @@ -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; diff --git a/web/components/src/confirm/ConfirmDialog.tsx b/web/components/src/confirm/ConfirmDialog.tsx new file mode 100644 index 0000000..2797f39 --- /dev/null +++ b/web/components/src/confirm/ConfirmDialog.tsx @@ -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 ( + +
+
+ {TextUtil.isEmpty(confirmState.text) ? ( + + ) : ( + confirmState.text + )} +
+
+
+
+
+ ); +} diff --git a/web/components/src/confirm/ConfirmProvider.tsx b/web/components/src/confirm/ConfirmProvider.tsx new file mode 100644 index 0000000..5a295f1 --- /dev/null +++ b/web/components/src/confirm/ConfirmProvider.tsx @@ -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 ( + + {children} + + ); +} diff --git a/web/components/src/confirm/ConfirmReducer.ts b/web/components/src/confirm/ConfirmReducer.ts new file mode 100644 index 0000000..56ffb51 --- /dev/null +++ b/web/components/src/confirm/ConfirmReducer.ts @@ -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; + } +}; diff --git a/web/components/src/confirm/useConfirm.ts b/web/components/src/confirm/useConfirm.ts new file mode 100644 index 0000000..f0be04d --- /dev/null +++ b/web/components/src/confirm/useConfirm.ts @@ -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 }; +} diff --git a/web/components/src/form/Form.tsx b/web/components/src/form/Form.tsx new file mode 100644 index 0000000..54b935e --- /dev/null +++ b/web/components/src/form/Form.tsx @@ -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 = UseFormReset; +export type FormValidation = any; +/* eslint-enable @typescript-eslint/no-explicit-any */ +type HandleSubmit = (values: T) => Promise; + +type FormDefaultValues = Record< + string, + unknown[] | boolean | number | string | undefined +>; +export interface IFormProperties { + autocomplete?: string; + children: React.ReactNode; + className?: string; + defaultValues?: DefaultValues; + handleSubmit?: HandleSubmit; + isDisabled?: boolean; + validation?: FormValidation; +} +export interface IFormError { + message: string; + type: keyof RegisterOptions | "email" | "manual"; +} + +const Form = ( + props: IFormProperties, +): React.ReactElement => { + const { + children, + handleSubmit: handleSubmitProp = noop, + validation, + className, + defaultValues, + autocomplete, + } = props; + const methods = useForm({ + defaultValues, + mode: "onBlur", + ...(validation && { + resolver: yupResolver(validation), + }), + }); + + const { handleSubmit } = methods; + + const onSubmit = useMemo( + () => handleSubmit(async (values) => handleSubmitProp(values)), + [handleSubmit, handleSubmitProp], + ); + + return ( + +
+ {children} +
+
+ ); +}; + +export default Form; diff --git a/web/components/src/form/checkbox/CheckboxInput.tsx b/web/components/src/form/checkbox/CheckboxInput.tsx new file mode 100644 index 0000000..35515d7 --- /dev/null +++ b/web/components/src/form/checkbox/CheckboxInput.tsx @@ -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 ( + { + const { + field: { onChange, ...field }, + } = props; + return ( +
+ + {/** biome-ignore lint/a11y/useSemanticElements: todo*/} +
{ + // 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 ? : null} +
+ + {[null, undefined].includes(label as null) + ? null + : label} + {children} + +
+ ); + }} + /> + ); +} diff --git a/web/components/src/form/select/SelectInput.tsx b/web/components/src/form/select/SelectInput.tsx new file mode 100644 index 0000000..432b398 --- /dev/null +++ b/web/components/src/form/select/SelectInput.tsx @@ -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 ( + { + return ( + + ); + }} + /> + ); +}; + +export default memo(SelectInput); diff --git a/web/components/src/form/select/types.ts b/web/components/src/form/select/types.ts new file mode 100644 index 0000000..8c7ddea --- /dev/null +++ b/web/components/src/form/select/types.ts @@ -0,0 +1,4 @@ +export interface SelectInputValue { + label: string; + value: string | number | boolean; +} diff --git a/web/components/src/form/text/TextInput.tsx b/web/components/src/form/text/TextInput.tsx new file mode 100644 index 0000000..aa5d481 --- /dev/null +++ b/web/components/src/form/text/TextInput.tsx @@ -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 ( + ( +
+ {type === "textarea" ? ( +