This commit is contained in:
2025-11-03 12:24:01 +02:00
commit 0806865287
177 changed files with 18453 additions and 0 deletions

28
server/.env.example Normal file
View File

@@ -0,0 +1,28 @@
# Server
SERVER_PORT=8555
SERVER_ENV=development
# Database
DB_HOST=localhost
DB_PORT=8553
DB_USER=postgres
DB_PASSWORD=postgres
DB_NAME=biostacker
DB_SSL_MODE=disable
# Redis
REDIS_ADDR=localhost:8554
REDIS_PASSWORD=
REDIS_DB=0
# SMTP
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_USERNAME=your-email@gmail.com
SMTP_PASSWORD=your-app-specific-password
SMTP_FROM=noreply@yourapp.com
# JWT
JWT_SECRET=LS0tLS1CRUdJTiBQUklWQVRFIEtFWS0tLS0tCk1JSUV2UUlCQURBTkJna3Foa2lHOXcwQkFRRUZBQVNDQktjd2dnU2pBZ0VBQW9JQkFRQ2hLSFdmNmFTUjFjeW8KdHVCNzBEV1J6WEx4V2lZaHgrWXB6QzV2dGxDWkFteU1HM3RxTzd3RjlFczdqVWtjY2dJQ0VzamZHZDQrOEVsaAppSU1DNnN2ZmNpcUZ4YldqbmliY0dOdFV0dmpzU3NxdmhHTDZKVGxyS3k0NlF4anlHUTRZdm40RVFqb0hkbE5rCnRyMXdVWXdFU0VXYUppQ0MwdTd3SWpoWUh5alJaNUVSWExsa29SUnIxNUt4Z2tTZ1kwUjlURS9LaWVraXZIdXgKT092QWh6UWF6KzdsckpIcWZoL1g5OWtLbjBoeTh4dXFJWXoxUVFnMUQ3S0twcXRnMUFRbDBRSDlBL1hQenhaZgoyWXZodkRrNnVmOHh5RUJZUlFPa2xiTGVEdUVZNXRTVTVaOFFvU2pMQmpwdTBMdC9hMmYxWlBmWW91Y2svcFZiCk9DVGliOWdyQWdNQkFBRUNnZ0VBUjhYd3FPRVNHWjhaOEZQT0kyWkZ6V011RzE1V1dEb2lnQi8rMkdMZWYxNnMKZ0RPbklkZHJ0RTBxQ21Jd212b05lZVhxenkzQ3BNNDRLRGQzZlorYlg2OEZVQ0dPOVVrMHJsWmxyRk4zQmltRApIbXM3OTRNSGtQcWdzbkw2azZ2ajh0STM1bWFtV0hkeXlZcDNVU1FJVTBucXNhM2tVYzltZWMwTHdmZFNvdnVqClhndkUxRzhPSGN4cEpLNTZTOHM2RklLTHc4aW1rT1ZSZFVLWGoxdFNyd0VKTHU2emR4UVdWMnQvRXZRakkvVVEKNjZ5MHhiTzRGb1ZvekhYd3NrWlNBbjlkVzcxRHVKVjRrQjZuOTcvaUZFZ3p6dVRjVU1UMS8xTndDS0hlQVo5TgpGbUhnZkxIbTRuRGJQUUpEWHJjY3JwNkJ2OVIrakNVOUlRMG5TTndsbVFLQmdRRFY3bjAzbFZNd1hPUjlZUEhHCjVndkU2d3JWWmVkL2pRMU9IQnVDbzlEc2Z5ZjI2UTNMMlVITm1QNm44ZnJ0UmVQa3U4YkUySzBHMm03SGsvVG4KbnVndXFUUGtocnlVSGJDeUtkUDRreU5EbXl2bWhhOFVaSXJXNW90cEFTbm8vZHF3SlBOQmpZZ2RnQk1zMGhZago1RHJOYm5hSlFVN3ZKNXRhRkZtSlR5QUc2UUtCZ1FEQTJVMC8xNXg5bWtpUnNyYS8rTklwQUN2YkFGM1NOQTFpCkNUa3NDRDhIejJnaDhtdXlpRG1zVk9Tc0lEUjkxbk1ZRVpHT2FLbEVpbzE4enBRazRDUWh0MUJkNVhoMnUrc2kKRmluOXlDd0VsSjZrVUFRS2NqZnFoMG5lcnAvZ0YxRXVNcHFCRUhFR0t1WUZ3QlJ6YjIrOTN5Yk8xdndpUW9ORwpCd0s5cEROaDh3S0JnUURReVg0cHRqSEhYSkdmRC9OSGRCTCtiNHBXTkt0WG4vamhSNnROdDhWYVdzdE5QYXk2ClMySGVYemdCL3JjdnhPc2l2R1RFanRkbmZkMXFLS3QzTm01Unc4OGlkS0V0U1VDKzBQWFFmd0dHcExXV3VOZmoKWmpEZWhZaC94YVA2Z1c1aVJOMm9GNUpGZ0U2MmlwOFREbGFaVWZxY0FFSWlSQnhwTUwwbHRqU0NxUUtCZ0FLUQpJTVd6Y09IK2RlNXh5Sm4ralpSNzZ4bExCUFF4T3VoTnBSUGZ2QzYzWS9QbmkrVGdpSnV3dVNWTWZFWWIzb1c0ClhnM2RlRHB2K1BkcXEyOWVCenpuZWNyMXJNY3ZNaTNPeTVvUzJmcnBtcjRtVGhkeGN5ckx4NENOSTVUUDJvVloKcU5JRVRPdy9EN1dOMnZlNXlHdG1sdFp5NXdEeGoxc1Q1c1pzY3o1ZkFvR0FVQ1JLbWRBaVlHMU9UUzk2aHRKWgp1aXl5amtaeGN0Z3RHWGNkNVF4Nm1TY3U0ZC83Snl2SFQyU0x6U0RYTjI1ZWpjSDc0S1B2bU5yYmNNdGNzMi9pCmVRM3pyQWR0T3FmTzZ6OWJHQm5Ya2gxZmFPazZjK092SFZVNUVhWjdia3VkUEdFU3VtRm9iREh6RzUvUmZpVzYKeFFQak1mMGQ1TUdkNWhqaEFDT0tPTHc9Ci0tLS0tRU5EIFBSSVZBVEUgS0VZLS0tLS0K
JWT_EXPIRY=480h
JWT_PUBLIC_KEY=LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0KTUlJQklqQU5CZ2txaGtpRzl3MEJBUUVGQUFPQ0FROEFNSUlCQ2dLQ0FRRUFvU2gxbitta2tkWE1xTGJnZTlBMQprYzF5OFZvbUljZm1LY3d1YjdaUW1RSnNqQnQ3YWp1OEJmUkxPNDFKSEhJQ0FoTEkzeG5lUHZCSllZaURBdXJMCjMzSXFoY1cxbzU0bTNCamJWTGI0N0VyS3I0UmkraVU1YXlzdU9rTVk4aGtPR0w1K0JFSTZCM1pUWkxhOWNGR00KQkVoRm1pWWdndEx1OENJNFdCOG8wV2VSRVZ5NVpLRVVhOWVTc1lKRW9HTkVmVXhQeW9ucElyeDdzVGpyd0ljMApHcy91NWF5UjZuNGYxL2ZaQ3A5SWN2TWJxaUdNOVVFSU5RK3lpcWFyWU5RRUpkRUIvUVAxejg4V1g5bUw0Ync1Ck9ybi9NY2hBV0VVRHBKV3kzZzdoR09iVWxPV2ZFS0VveXdZNmJ0QzdmMnRuOVdUMzJLTG5KUDZWV3pnazRtL1kKS3dJREFRQUIKLS0tLS1FTkQgUFVCTElDIEtFWS0tLS0tCg==

28
server/.gitignore vendored Normal file
View File

@@ -0,0 +1,28 @@
# Binaries
*.exe
*.exe~
*.dll
*.so
*.dylib
# Build
bin/
dist/
# Environment
.env
# IDE
.idea/
.vscode/
# Dependency directories
vendor/
# Test binary, built with go test -c
*.test
# Output of the go coverage tool
*.out
tmp/

30
server/Dockerfile Normal file
View File

@@ -0,0 +1,30 @@
# Use smaller Golang image
FROM golang:alpine AS builder
WORKDIR /app
# Install required dependencies (git for go mod)
RUN apk add --no-cache git
# Copy and download dependencies
COPY go.mod go.sum ./
RUN go mod download && go mod verify
# Copy remaining application files
COPY . .
# Build the application as a statically linked binary
RUN CGO_ENABLED=0 GOOS=linux go build -o main ./cmd/api
# Use minimal final image
FROM alpine:latest
WORKDIR /app
COPY --from=builder /app/main .
COPY .env .
# Expose port
EXPOSE 8080
# Run application
CMD ["./main"]

41
server/Makefile Normal file
View File

@@ -0,0 +1,41 @@
.PHONY: build run test docker-build docker-run dev clean fmt lint vet staticcheck install-lint
APP_NAME=go-server
build:
mkdir -p bin
go build -o bin/app cmd/api/main.go
run:
go run cmd/api/main.go
test:
go test -v ./...
docker-build:
docker build -t $(APP_NAME) .
docker-run:
docker compose up -d
dev:
air
clean:
rm -rf bin/ tmp/
fmt:
go fmt ./...
lint:
golangci-lint run ./...
vet:
go vet ./...
staticcheck:
go install honnef.co/go/tools/cmd/staticcheck@latest
staticcheck ./...
install-lint:
go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest

70
server/README.md Normal file
View File

@@ -0,0 +1,70 @@
# Go Server
A scalable backend boilerplate built with **Go**, **Gin**, **GORM**, **PostgreSQL**, and **Redis**.
---
## Technologies Used
- **Go** - Core language
- **Gin** - HTTP framework
- **GORM** - ORM for PostgreSQL
- **Redis** - In-memory caching
- **Docker** - Containerization (optional)
## Features
- **RESTful API** using Gin
- **PostgreSQL + Redis** integration
- **Modular service-based architecture**
- **Middleware support** (CORS, Logging)
- **Graceful shutdown handling**
- **Environment variable configuration**
---
## Getting Started
### Installation
1. Create a `.env` file and configure database credentials:
```bash
cp .env.example .env
```
2. Install dependencies:
```bash
go mod tidy
```
3. Run the database migrations (if applicable):
```bash
go run scripts/migrate.go
```
4. Start the server:
```bash
go run main.go
```
The server should be running at **http://localhost:8080**
---
## 📂 Project Structure
```
├── config/ # Configuration files
├── controllers/ # API Controllers
├── models/ # Database Models
├── routes/ # Route Handlers
├── services/ # Business Logic Layer
├── database/ # DB Connection & Migrations
├── middleware/ # Middleware (CORS, Auth, Logging)
├── main.go # Entry Point
└── .env.example # Environment Config Sample
```

BIN
server/api Executable file

Binary file not shown.

97
server/cmd/api/main.go Normal file
View File

@@ -0,0 +1,97 @@
package main
import (
"context"
"fmt"
"log"
"os"
"os/signal"
"syscall"
"time"
"go-server/internal/config"
"go-server/internal/database"
"go-server/internal/models"
"go-server/internal/routes"
"go-server/internal/server"
"gorm.io/gorm"
)
func main() {
fmt.Println("Starting server...")
// Load configuration
cfg, err := config.LoadConfig()
if err != nil {
log.Fatalf("Failed to load config: %v", err)
}
// init dbs
_ = database.InitDatabases(database.NewPostgresConfig(), database.RedisConfig(cfg.Redis))
// Initialize PostgreSQL
db := database.GetPostgres()
sqlDb, err := db.DB()
if err != nil {
log.Fatalf("Failed to get DB connection: %v", err)
}
defer sqlDb.Close()
// Auto-migrate models to ensure GORM knows about table structures
err = migrateModels(db)
if err != nil {
log.Fatalf("Failed to migrate models: %v", err)
}
// Initialize Redis
redisClient := database.GetRedis()
defer redisClient.Close()
// Setup router
router := routes.SetupRouter(db)
// Use the server abstraction
srv := server.NewServer(router)
// Handle graceful shutdown
quit := make(chan os.Signal, 1)
signal.Notify(quit, os.Interrupt, syscall.SIGTERM)
go func() {
<-quit
fmt.Println("Shutting down server...")
// Create shutdown context with a timeout
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
// Shutdown services gracefully
if err := srv.Shutdown(ctx); err != nil {
log.Fatalf("Server shutdown failed: %v", err)
}
redisClient.Close()
sqlDb.Close()
fmt.Println("Server gracefully stopped")
}()
// Start server
port := cfg.Server.Port
if err := srv.Start(port); err != nil {
log.Fatalf("Failed to start server: %v", err)
}
}
// migrateModels auto-migrates all models to ensure GORM knows about table structures
func migrateModels(db *gorm.DB) error {
return db.AutoMigrate(
models.User{},
models.Supplement{},
models.Nutrient{},
models.Category{},
models.NutrientCategory{},
models.SupplementNutrient{},
models.Todo{},
models.TodoCompletion{},
)
}

66
server/compose.yaml Normal file
View File

@@ -0,0 +1,66 @@
services:
# app:
# env_file:
# - .env
# build:
# context: .
# dockerfile: Dockerfile
# ports:
# - "${SERVER_PORT}:${SERVER_PORT}"
# depends_on:
# - postgres
# - mongodb
# - redis
# environment:
# # Server
# - SERVER_PORT=${SERVER_PORT}
# - SERVER_ENV=${SERVER_ENV}
# # Database
# - DB_HOST=${DB_HOST}
# - DB_PORT=${DB_PORT}
# - DB_USER=${DB_USER}
# - DB_PASSWORD=${DB_PASSWORD}
# - DB_NAME=${DB_NAME}
# - DB_SSL_MODE=${DB_SSL_MODE}
# # Redis
# - REDIS_ADDR=${REDIS_ADDR}
# - REDIS_PASSWORD=${REDIS_PASSWORD}
# - REDIS_DB=${REDIS_DB}
# networks:
# - app-network
# restart: unless-stopped
postgres:
image: postgres:17-alpine
environment:
- POSTGRES_USER=${DB_USER}
- POSTGRES_PASSWORD=${DB_PASSWORD}
- POSTGRES_DB=${DB_NAME}
ports:
- "8553:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
networks:
- app-network
restart: unless-stopped
redis:
image: redis:alpine
ports:
- "8554:6379"
volumes:
- redis_data:/data
networks:
- app-network
restart: unless-stopped
networks:
app-network:
driver: bridge
volumes:
postgres_data:
mongodb_data:
redis_data:

69
server/go.mod Normal file
View File

@@ -0,0 +1,69 @@
module go-server
go 1.23.0
toolchain go1.24.5
require github.com/spf13/viper v1.20.1
require (
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/oklog/ulid/v2 v2.1.1 // indirect
)
require (
github.com/bytedance/sonic v1.14.0 // indirect
github.com/bytedance/sonic/loader v0.3.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudwego/base64x v0.1.5 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/fsnotify/fsnotify v1.9.0
github.com/gabriel-vasile/mimetype v1.4.9 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/gin-gonic/gin v1.10.1
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.27.0
github.com/goccy/go-json v0.10.5 // indirect
github.com/golang-jwt/jwt/v5 v5.2.3
github.com/google/uuid v1.6.0
github.com/hibiken/asynq v0.25.1
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.7.5 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/redis/go-redis/v9 v9.11.0
github.com/robfig/cron/v3 v3.0.1 // indirect
github.com/sagikazarmark/locafero v0.9.0 // indirect
github.com/sourcegraph/conc v0.3.0 // indirect
github.com/spf13/afero v1.14.0 // indirect
github.com/spf13/cast v1.9.2 // indirect
github.com/spf13/pflag v1.0.7 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.uber.org/zap v1.27.0
golang.org/x/arch v0.19.0 // indirect
golang.org/x/crypto v0.40.0
golang.org/x/net v0.42.0 // indirect
golang.org/x/sync v0.16.0 // indirect
golang.org/x/sys v0.34.0 // indirect
golang.org/x/text v0.27.0 // indirect
golang.org/x/time v0.12.0 // indirect
google.golang.org/protobuf v1.36.6 // indirect
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df
gopkg.in/yaml.v3 v3.0.1 // indirect
gorm.io/driver/postgres v1.6.0
gorm.io/gorm v1.30.1
)

160
server/go.sum Normal file
View File

@@ -0,0 +1,160 @@
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ=
github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA=
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4=
github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY=
github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ=
github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/golang-jwt/jwt/v5 v5.2.3 h1:kkGXqQOBSDDWRhWNXTFpqGSCMyh/PLnqUvMGJPDJDs0=
github.com/golang-jwt/jwt/v5 v5.2.3/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hibiken/asynq v0.25.1 h1:phj028N0nm15n8O2ims+IvJ2gz4k2auvermngh9JhTw=
github.com/hibiken/asynq v0.25.1/go.mod h1:pazWNOLBu0FEynQRBvHA26qdIKRSmfdIfUm4HdsLmXg=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.7.5 h1:JHGfMnQY+IEtGM63d+NGMjoRpysB2JBwDr5fsngwmJs=
github.com/jackc/pgx/v5 v5.7.5/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s=
github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ=
github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/redis/go-redis/v9 v9.11.0 h1:E3S08Gl/nJNn5vkxd2i78wZxWAPNZgUNTp8WIJUAiIs=
github.com/redis/go-redis/v9 v9.11.0/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/sagikazarmark/locafero v0.9.0 h1:GbgQGNtTrEmddYDSAH9QLRyfAHY12md+8YFTqyMTC9k=
github.com/sagikazarmark/locafero v0.9.0/go.mod h1:UBUyz37V+EdMS3hDF3QWIiVr/2dPrx49OMO0Bn0hJqk=
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
github.com/spf13/afero v1.14.0 h1:9tH6MapGnn/j0eb0yIXiLjERO8RB6xIVZRDCX7PtqWA=
github.com/spf13/afero v1.14.0/go.mod h1:acJQ8t0ohCGuMN3O+Pv0V0hgMxNYDlvdk+VTfyZmbYo=
github.com/spf13/cast v1.9.2 h1:SsGfm7M8QOFtEzumm7UZrZdLLquNdzFYfIbEXntcFbE=
github.com/spf13/cast v1.9.2/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
github.com/spf13/pflag v1.0.7 h1:vN6T9TfwStFPFM5XzjsvmzZkLuaLX+HS+0SeFLRgU6M=
github.com/spf13/pflag v1.0.7/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4=
github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
golang.org/x/arch v0.19.0 h1:LmbDQUodHThXE+htjrnmVD73M//D9GTH6wFZjyDkjyU=
golang.org/x/arch v0.19.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk=
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod h1:m7x9LTH6d71AHyAX77c9yqWCCa3UKHcVEj9y7hAtKDk=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df h1:n7WqCuqOuCbNr617RXOY0AWRXxgwEyPp2z+p0+hgMuE=
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df/go.mod h1:LRQQ+SO6ZHR7tOkpBDuZnXENFzX8qRjMDMyPD6BRkCw=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
gorm.io/gorm v1.30.1 h1:lSHg33jJTBxs2mgJRfRZeLDG+WZaHYCk3Wtfl6Ngzo4=
gorm.io/gorm v1.30.1/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE=
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=

View File

@@ -0,0 +1,208 @@
// internal/config/config.go
package config
import (
"fmt"
"os"
"path/filepath"
"sync"
"github.com/fsnotify/fsnotify"
"github.com/spf13/viper"
)
type Config struct {
Server ServerConfig
Database DatabaseConfig
Redis RedisConfig
SMTP SMTPConfig
JWT JWTConfig
}
type ServerConfig struct {
Port string
Env string
}
type DatabaseConfig struct {
Host string
Port string
User string
Password string
Name string
SSLMode string
}
type RedisConfig struct {
Addr string
Password string
DB int
}
type SMTPConfig struct {
Host string
Port int
Username string
Password string
From string
}
type JWTConfig struct {
Secret string
PublicKey string
Expiry string
}
// Package-level state for a single, watched config instance
var (
vip *viper.Viper
cfgCache *Config
cfgMu sync.RWMutex
initOnce sync.Once
initErr error
listeners []func(*Config)
listenersMu sync.RWMutex
)
// LoadConfig returns the current configuration. On first call it initializes
// Viper, loads the config from a deterministic location, and starts watching
// for changes. Subsequent calls are cheap and thread-safe.
func LoadConfig() (*Config, error) {
initOnce.Do(func() {
vip = viper.New()
configureViper(vip)
// Initial read must succeed; later changes are handled via WatchConfig
if err := vip.ReadInConfig(); err != nil {
initErr = fmt.Errorf("failed to load config: %w", err)
return
}
// Build cache from initial values
cfg := buildFromViper(vip)
cfgMu.Lock()
cfgCache = cfg
cfgMu.Unlock()
// Watch for changes and update cache atomically
vip.WatchConfig()
vip.OnConfigChange(func(_ fsnotify.Event) {
// viper already re-reads on change; rebuild view and swap
newCfg := buildFromViper(vip)
cfgMu.Lock()
cfgCache = newCfg
cfgMu.Unlock()
notifyListeners(newCfg)
})
})
if initErr != nil {
return nil, initErr
}
cfgMu.RLock()
defer cfgMu.RUnlock()
return cloneConfig(cfgCache), nil
}
// DSN returns the Data Source Name for PostgreSQL connection
func (db DatabaseConfig) DSN() string {
return fmt.Sprintf(
"postgres://%s:%s@%s:%s/%s?sslmode=%s",
db.User, db.Password, db.Host, db.Port, db.Name, db.SSLMode,
)
}
// RegisterChangeListener registers a callback invoked whenever the configuration
// file changes and the in-memory configuration is updated.
func RegisterChangeListener(listener func(*Config)) {
listenersMu.Lock()
defer listenersMu.Unlock()
listeners = append(listeners, listener)
}
// --- internal helpers ---
func configureViper(v *viper.Viper) {
// Allow env vars to override any key (expects exact key names used below)
v.AutomaticEnv()
if path, ok := resolveConfigFilePath(); ok {
v.SetConfigFile(path)
return
}
// Fallback to looking for a .env in stable, nearby locations.
v.SetConfigName(".env")
v.SetConfigType("env")
v.AddConfigPath(".")
v.AddConfigPath("..")
}
func resolveConfigFilePath() (string, bool) {
if p := os.Getenv("CONFIG_FILE"); p != "" {
return p, true
}
cwd, _ := os.Getwd()
candidates := []string{
filepath.Join(cwd, ".env"),
filepath.Join(cwd, "go-server", ".env"),
filepath.Join(cwd, "..", ".env"),
filepath.Join(cwd, "..", "go-server", ".env"),
}
for _, c := range candidates {
if info, err := os.Stat(c); err == nil && !info.IsDir() {
return c, true
}
}
return "", false
}
func buildFromViper(v *viper.Viper) *Config {
return &Config{
Server: ServerConfig{
Port: v.GetString("SERVER_PORT"),
Env: v.GetString("SERVER_ENV"),
},
Database: DatabaseConfig{
Host: v.GetString("DB_HOST"),
Port: v.GetString("DB_PORT"),
User: v.GetString("DB_USER"),
Password: v.GetString("DB_PASSWORD"),
Name: v.GetString("DB_NAME"),
SSLMode: v.GetString("DB_SSL_MODE"),
},
Redis: RedisConfig{
Addr: v.GetString("REDIS_ADDR"),
Password: v.GetString("REDIS_PASSWORD"),
DB: v.GetInt("REDIS_DB"),
},
SMTP: SMTPConfig{
Host: v.GetString("SMTP_HOST"),
Port: v.GetInt("SMTP_PORT"),
Username: v.GetString("SMTP_USERNAME"),
Password: v.GetString("SMTP_PASSWORD"),
From: v.GetString("SMTP_FROM"),
},
JWT: JWTConfig{
Secret: v.GetString("JWT_SECRET"),
Expiry: v.GetString("JWT_EXPIRY"),
PublicKey: v.GetString("JWT_PUBLIC_KEY"),
},
}
}
func cloneConfig(c *Config) *Config {
if c == nil {
return nil
}
cc := *c
return &cc
}
func notifyListeners(c *Config) {
listenersMu.RLock()
defer listenersMu.RUnlock()
for _, l := range listeners {
// Call listeners in-goroutine to avoid blocking the watcher
go l(cloneConfig(c))
}
}

View File

@@ -0,0 +1,34 @@
// internal/database/db.go
package database
import (
"github.com/redis/go-redis/v9"
"gorm.io/gorm"
)
var (
pgDB *gorm.DB
redisClient *redis.Client
)
func InitDatabases(pgConfig PostgresConfig, redisConfig RedisConfig) error {
var err error
pgDB, err = NewPostgresConnection(pgConfig)
if err != nil {
return err
}
redisClient, err = NewRedisConnection(redisConfig)
if err != nil {
return err
}
return nil
}
func GetPostgres() *gorm.DB {
return pgDB
}
func GetRedis() *redis.Client {
return redisClient
}

View File

@@ -0,0 +1,64 @@
// internal/database/postgresql.go
package database
import (
"fmt"
"log"
"time"
"go-server/internal/config"
"gorm.io/driver/postgres"
"gorm.io/gorm"
)
type PostgresConfig struct {
Host string
Port string
User string
Password string
DBName string
SSLMode string
}
func NewPostgresConfig() PostgresConfig {
cfg, err := config.LoadConfig()
if err != nil {
log.Fatalf("Failed to load postgres config: %v", err)
}
return PostgresConfig{
Host: cfg.Database.Host,
Port: cfg.Database.Port,
User: cfg.Database.User,
Password: cfg.Database.Password,
DBName: cfg.Database.Name,
SSLMode: cfg.Database.SSLMode,
}
}
func NewPostgresConnection(config PostgresConfig) (*gorm.DB, error) {
dsn := fmt.Sprintf("host=%s user=%s password=%s dbname=%s port=%s sslmode=%s",
config.Host, config.User, config.Password, config.DBName, config.Port, config.SSLMode)
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{
PrepareStmt: true,
SkipDefaultTransaction: true,
// Logger can be enabled if needed for debugging
// Logger: logger.Default.LogMode(logger.Info),
})
if err != nil {
return nil, fmt.Errorf("failed to connect to database: %w", err)
}
// Configure database/sql connection pool
sqlDB, err := db.DB()
if err == nil {
sqlDB.SetMaxIdleConns(10)
sqlDB.SetMaxOpenConns(50)
sqlDB.SetConnMaxLifetime(60 * time.Minute)
sqlDB.SetConnMaxIdleTime(10 * time.Minute)
}
return db, nil
}

View File

@@ -0,0 +1,31 @@
// internal/database/redis.go
package database
import (
"context"
"fmt"
"github.com/redis/go-redis/v9"
)
type RedisConfig struct {
Addr string
Password string
DB int
}
func NewRedisConnection(config RedisConfig) (*redis.Client, error) {
client := redis.NewClient(&redis.Options{
Addr: config.Addr,
Password: config.Password,
DB: config.DB,
})
// Test connection
ctx := context.Background()
if err := client.Ping(ctx).Err(); err != nil {
return nil, fmt.Errorf("failed to connect to redis: %w", err)
}
return client, nil
}

View File

@@ -0,0 +1,64 @@
// internal/email/sender.go
package email
import (
"bytes"
"fmt"
"html/template"
"gopkg.in/gomail.v2"
)
type EmailConfig struct {
Host string
Port int
Username string
Password string
From string
}
type EmailService struct {
config EmailConfig
dialer *gomail.Dialer
templates *template.Template
}
func NewEmailService(config EmailConfig) (*EmailService, error) {
// Load email templates
templates, err := template.ParseGlob("internal/email/templates/*.html")
if err != nil {
return nil, fmt.Errorf("failed to load email templates: %w", err)
}
dialer := gomail.NewDialer(
config.Host,
config.Port,
config.Username,
config.Password,
)
return &EmailService{
config: config,
dialer: dialer,
templates: templates,
}, nil
}
func (s *EmailService) SendEmail(to []string, subject string, templateName string, data interface{}) error {
var body bytes.Buffer
if err := s.templates.ExecuteTemplate(&body, templateName, data); err != nil {
return fmt.Errorf("failed to execute template: %w", err)
}
m := gomail.NewMessage()
m.SetHeader("From", s.config.From)
m.SetHeader("To", to...)
m.SetHeader("Subject", subject)
m.SetBody("text/html", body.String())
if err := s.dialer.DialAndSend(m); err != nil {
return fmt.Errorf("failed to send email: %w", err)
}
return nil
}

View File

@@ -0,0 +1,41 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<title>{{.Subject}}</title>
<style>
body {
font-family: Arial, sans-serif;
line-height: 1.6;
color: #333;
max-width: 600px;
margin: 0 auto;
padding: 20px;
}
.header {
background-color: #f8f9fa;
padding: 20px;
text-align: center;
margin-bottom: 20px;
}
.content {
padding: 20px;
}
.footer {
text-align: center;
padding: 20px;
font-size: 12px;
color: #666;
}
</style>
</head>
<body>
<div class="header">
<h1>{{.Title}}</h1>
</div>
<div class="content">{{.Content}}</div>
<div class="footer">
<p>© {{.Year}} Your Company. All rights reserved.</p>
</div>
</body>
</html>

View File

@@ -0,0 +1,25 @@
package handlers
import (
"go-server/internal/service"
"net/http"
"github.com/gin-gonic/gin"
)
type CategoryController struct {
categoryService *service.CategoryService
}
func NewCategoryController(categoryService *service.CategoryService) *CategoryController {
return &CategoryController{categoryService: categoryService}
}
func (h *CategoryController) GetAll(c *gin.Context) {
categories, err := h.categoryService.GetAll(c)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, categories)
}

View File

@@ -0,0 +1,93 @@
package handlers
import (
"go-server/internal/service"
"net/http"
"strings"
"github.com/gin-gonic/gin"
)
type DailyOverviewController struct {
service *service.DailyOverviewService
}
func NewDailyOverviewController(service *service.DailyOverviewService) *DailyOverviewController {
return &DailyOverviewController{
service: service,
}
}
// GetDailyOverview returns comprehensive daily nutrient overview
// GET /api/daily-overview/overview
func (h *DailyOverviewController) GetDailyOverview(c *gin.Context) {
overview, err := h.service.GetDailyOverview(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get daily overview"})
return
}
c.JSON(http.StatusOK, overview)
}
// GetNutrientTotals returns just the aggregated nutrient totals
// GET /api/daily-overview/totals
func (h *DailyOverviewController) GetNutrientTotals(c *gin.Context) {
totals, err := h.service.GetNutrientTotals(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get nutrient totals"})
return
}
c.JSON(http.StatusOK, totals)
}
// GetSupplementBreakdown returns detailed breakdown by supplement
// GET /api/daily-overview/breakdown
func (h *DailyOverviewController) GetSupplementBreakdown(c *gin.Context) {
breakdown, err := h.service.GetSupplementBreakdown(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get supplement breakdown"})
return
}
c.JSON(http.StatusOK, breakdown)
}
// ExecuteCustomQuery allows executing custom SQL queries via API
// POST /api/daily-overview/query
// Body: {"query": "SELECT ...", "args": [...]}
func (h *DailyOverviewController) ExecuteCustomQuery(c *gin.Context) {
var request struct {
Query string `json:"query"`
Args []interface{} `json:"args"`
}
if err := c.ShouldBindJSON(&request); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
return
}
if request.Query == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Query is required"})
return
}
// Basic security: only allow SELECT statements
queryUpper := strings.ToUpper(strings.TrimSpace(request.Query))
if !strings.HasPrefix(queryUpper, "SELECT") {
c.JSON(http.StatusBadRequest, gin.H{"error": "Only SELECT queries are allowed"})
return
}
results, err := h.service.ExecuteCustomQuery(c.Request.Context(), request.Query, request.Args...)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to execute query"})
return
}
c.JSON(http.StatusOK, gin.H{
"results": results,
"count": len(results),
})
}

View File

@@ -0,0 +1,26 @@
package handlers
import (
"net/http"
"go-server/internal/service"
"github.com/gin-gonic/gin"
)
type NutrientController struct {
service *service.NutrientService
}
func NewNutrientController(service *service.NutrientService) *NutrientController {
return &NutrientController{service}
}
func (c *NutrientController) GetAll(ctx *gin.Context) {
nutrients, err := c.service.GetAll(ctx)
if err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
ctx.JSON(http.StatusOK, nutrients)
}

View File

@@ -0,0 +1,35 @@
package handlers
import (
"net/http"
"go-server/internal/service"
"github.com/gin-gonic/gin"
)
type SupplementController struct {
service *service.SupplementService
}
func NewSupplementController(service *service.SupplementService) *SupplementController {
return &SupplementController{service}
}
func (c *SupplementController) GetAll(ctx *gin.Context) {
supplements, err := c.service.GetAll(ctx)
if err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
ctx.JSON(http.StatusOK, supplements)
}
func (c *SupplementController) GetDailySupplementsOverview(ctx *gin.Context) {
supplements, err := c.service.GetDailySupplementsOverview(ctx)
if err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
ctx.JSON(http.StatusOK, supplements)
}

View File

@@ -0,0 +1,269 @@
package handlers
import (
"go-server/internal/models"
"go-server/internal/service"
"net/http"
"strconv"
"time"
"github.com/gin-gonic/gin"
)
type TodoController struct {
service *service.TodoService
}
func NewTodoController(service *service.TodoService) *TodoController {
return &TodoController{
service: service,
}
}
// CreateTodo creates a new todo
// POST /api/todo/create
func (h *TodoController) CreateTodo(c *gin.Context) {
userID := getUserIDFromContext(c) // You'll need to implement this helper
if userID.IsZero() {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return
}
var req service.CreateTodoRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
return
}
todo, err := h.service.CreateTodo(c.Request.Context(), userID, req)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create todo"})
return
}
c.JSON(http.StatusCreated, todo)
}
// GetTodos gets all todos with stats for the authenticated user
// GET /api/todo/list
func (h *TodoController) GetTodos(c *gin.Context) {
userID := getUserIDFromContext(c)
if userID.IsZero() {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return
}
todos, err := h.service.GetTodosWithStats(c.Request.Context(), userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get todos"})
return
}
c.JSON(http.StatusOK, todos)
}
// GetTodaysSummary gets today's todo summary
// GET /api/todo/today
func (h *TodoController) GetTodaysSummary(c *gin.Context) {
userID := getUserIDFromContext(c)
if userID.IsZero() {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return
}
summary, err := h.service.GetTodaysSummary(c.Request.Context(), userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get today's summary"})
return
}
c.JSON(http.StatusOK, summary)
}
// UpdateTodo updates a todo
// PUT /api/todo/:id
func (h *TodoController) UpdateTodo(c *gin.Context) {
userID := getUserIDFromContext(c)
if userID.IsZero() {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return
}
todoIDStr := c.Param("id")
todoID, err := models.ParseULID(todoIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid todo ID"})
return
}
var req service.UpdateTodoRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
return
}
todo, err := h.service.UpdateTodo(c.Request.Context(), todoID, userID, req)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update todo"})
return
}
c.JSON(http.StatusOK, todo)
}
// DeleteTodo deletes a todo
// DELETE /api/todo/:id
func (h *TodoController) DeleteTodo(c *gin.Context) {
userID := getUserIDFromContext(c)
if userID.IsZero() {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return
}
todoIDStr := c.Param("id")
todoID, err := models.ParseULID(todoIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid todo ID"})
return
}
err = h.service.DeleteTodo(c.Request.Context(), todoID, userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete todo"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Todo deleted successfully"})
}
// CompleteTodo marks a todo as completed
// POST /api/todo/:id/complete
func (h *TodoController) CompleteTodo(c *gin.Context) {
userID := getUserIDFromContext(c)
if userID.IsZero() {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return
}
todoIDStr := c.Param("id")
todoID, err := models.ParseULID(todoIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid todo ID"})
return
}
var req service.CompleteTodoRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
return
}
err = h.service.CompleteTodo(c.Request.Context(), todoID, userID, req)
if err != nil {
if err.Error() == "todo already completed today" {
c.JSON(http.StatusConflict, gin.H{"error": "Todo already completed today"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to complete todo"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Todo completed successfully"})
}
// GetActivityLog gets the user's activity log
// GET /api/todo/activity
func (h *TodoController) GetActivityLog(c *gin.Context) {
userID := getUserIDFromContext(c)
if userID.IsZero() {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return
}
// Parse pagination parameters
limitStr := c.DefaultQuery("limit", "50")
offsetStr := c.DefaultQuery("offset", "0")
limit, err := strconv.Atoi(limitStr)
if err != nil {
limit = 50
}
offset, err := strconv.Atoi(offsetStr)
if err != nil {
offset = 0
}
activities, err := h.service.GetActivityLog(c.Request.Context(), userID, limit, offset)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get activity log"})
return
}
c.JSON(http.StatusOK, activities)
}
// GetActivityLogByDate gets activity log for a specific date
// GET /api/todo/activity/:date
func (h *TodoController) GetActivityLogByDate(c *gin.Context) {
userID := getUserIDFromContext(c)
if userID.IsZero() {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return
}
dateStr := c.Param("date")
date, err := time.Parse("2006-01-02", dateStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid date format. Use YYYY-MM-DD"})
return
}
activities, err := h.service.GetActivityLogByDate(c.Request.Context(), userID, date)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get activity log"})
return
}
c.JSON(http.StatusOK, activities)
}
// GetWeeklySummary gets a weekly summary of todo completions
// GET /api/todo/weekly
func (h *TodoController) GetWeeklySummary(c *gin.Context) {
userID := getUserIDFromContext(c)
if userID.IsZero() {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return
}
summary, err := h.service.GetWeeklySummary(c.Request.Context(), userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get weekly summary"})
return
}
c.JSON(http.StatusOK, summary)
}
// Helper function to get user ID from context
// You'll need to implement this based on your authentication middleware
func getUserIDFromContext(c *gin.Context) models.ULID {
testUserId := models.MustParseULID("01K54QBS528HKQDF985XNW2J6R")
return testUserId
// This is a placeholder - implement based on your auth system
// For example, if you store the user in context after JWT validation:
// userInterface, exists := c.Get("user")
// if !exists {
// return models.ULID{} // Return zero value
// }
// user, ok := userInterface.(*models.User)
// if !ok {
// return models.ULID{} // Return zero value
// }
// return user.ID
}

View File

@@ -0,0 +1,81 @@
package handlers
import (
"net/http"
"go-server/internal/service"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
type AdminController struct {
service *service.UserService
}
func NewAdminController(service *service.UserService) *AdminController {
return &AdminController{service}
}
func (uc *AdminController) GetUserByID(c *gin.Context) {
id := uuid.MustParse(c.Param("id"))
user, err := uc.service.GetByID(c, id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
return
}
c.JSON(http.StatusOK, user)
}
func (uc *AdminController) GetUsers(c *gin.Context) {
users, err := uc.service.GetAll(c)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, users)
}
func (uc *AdminController) CreateUser(c *gin.Context) {
var input service.CreateUserInput
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request"})
return
}
user, err := uc.service.Create(c, input)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, user)
}
func (uc *AdminController) DeleteUser(c *gin.Context) {
id := uuid.MustParse(c.Param("id"))
err := uc.service.Delete(c, id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "User deleted successfully"})
}
func (uc *AdminController) ChangePassword(c *gin.Context) {
id := uuid.MustParse(c.Param("id"))
var input service.ChangePasswordInput
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request"})
return
}
err := uc.service.ChangePassword(c, id, input)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Password changed successfully"})
}

View File

@@ -0,0 +1,205 @@
package middleware
import (
"context"
"crypto/rsa"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"net/http"
"sync"
"time"
"go-server/internal/config"
"go-server/internal/database"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
)
// AuthRequired is a middleware to validate JWT token
func AuthRequired() gin.HandlerFunc {
return func(c *gin.Context) {
jwtHeader := c.GetHeader("x-jwt")
if jwtHeader == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "authorization header required"})
return
}
// Validate the token
claims, err := validateToken(jwtHeader)
if err != nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
return
}
// Attach user ID to the context
c.Set("userID", claims.UserID)
c.Next()
}
}
// JWTClaims represents the JWT claims
type JWTClaims struct {
UserID uuid.UUID `json:"user_id"`
jwt.RegisteredClaims
}
var (
publicKeyMu sync.RWMutex
publicKey *rsa.PublicKey
)
func init() {
// Initialize cached public key at startup
if cfg, err := config.LoadConfig(); err == nil {
if pk, err := parsePublicKeyFromConfig(cfg.JWT.PublicKey); err == nil {
publicKeyMu.Lock()
publicKey = pk
publicKeyMu.Unlock()
}
}
// Update cached key on config changes
config.RegisterChangeListener(func(c *config.Config) {
if c == nil {
return
}
if pk, err := parsePublicKeyFromConfig(c.JWT.PublicKey); err == nil {
publicKeyMu.Lock()
publicKey = pk
publicKeyMu.Unlock()
}
})
}
func parsePublicKeyFromConfig(publicKeyB64 string) (*rsa.PublicKey, error) {
if publicKeyB64 == "" {
return nil, errors.New("missing JWT public key")
}
publicKeyBytes, err := base64.StdEncoding.DecodeString(publicKeyB64)
if err != nil {
return nil, fmt.Errorf("failed to decode public key: %w", err)
}
pk, err := jwt.ParseRSAPublicKeyFromPEM(publicKeyBytes)
if err != nil {
return nil, fmt.Errorf("failed to parse public key: %w", err)
}
return pk, nil
}
// validateToken validates and parses the JWT token
func validateToken(tokenStr string) (*JWTClaims, error) {
// 1) Try Redis cache first
if claims := tryGetCachedClaims(tokenStr); claims != nil {
// Double-check expiration in case clock skew and guard rails
if claims.ExpiresAt != nil && claims.ExpiresAt.Before(time.Now()) {
return nil, errors.New("token expired")
}
return claims, nil
}
// 2) Validate signature using cached public key
publicKeyMu.RLock()
pk := publicKey
publicKeyMu.RUnlock()
if pk == nil {
return nil, errors.New("JWT public key not initialized")
}
token, err := jwt.ParseWithClaims(tokenStr, &JWTClaims{}, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return pk, nil
})
if err != nil {
return nil, err
}
claims, ok := token.Claims.(*JWTClaims)
if !ok || !token.Valid {
return nil, errors.New("invalid token")
}
// Check token expiration
if claims.ExpiresAt.Before(time.Now()) {
return nil, errors.New("token expired")
}
// 3) Store in Redis cache with TTL until expiry
cacheClaims(tokenStr, claims)
return claims, nil
}
// --- Redis cache helpers ---
const jwtCachePrefix = "jwt:cache:"
type cachedClaims struct {
UserID string `json:"user_id"`
Exp int64 `json:"exp"`
}
func tryGetCachedClaims(tokenStr string) *JWTClaims {
r := database.GetRedis()
if r == nil {
return nil
}
ctx := context.Background()
key := jwtCachePrefix + sha256Hex(tokenStr)
raw, err := r.Get(ctx, key).Bytes()
if err != nil {
return nil
}
var cc cachedClaims
if err := json.Unmarshal(raw, &cc); err != nil {
return nil
}
if cc.Exp <= time.Now().Unix() {
return nil
}
uid, err := uuid.Parse(cc.UserID)
if err != nil {
return nil
}
return &JWTClaims{
UserID: uid,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Unix(cc.Exp, 0)),
},
}
}
func cacheClaims(tokenStr string, claims *JWTClaims) {
if claims == nil || claims.ExpiresAt == nil {
return
}
ttl := time.Until(claims.ExpiresAt.Time)
if ttl <= 0 {
return
}
r := database.GetRedis()
if r == nil {
return
}
ctx := context.Background()
key := jwtCachePrefix + sha256Hex(tokenStr)
payload, err := json.Marshal(cachedClaims{
UserID: claims.UserID.String(),
Exp: claims.ExpiresAt.Unix(),
})
if err != nil {
return
}
_ = r.Set(ctx, key, payload, ttl).Err()
}
func sha256Hex(s string) string {
sum := sha256.Sum256([]byte(s))
return hex.EncodeToString(sum[:])
}

View File

@@ -0,0 +1,20 @@
// internal/middleware/cors.go
package middleware
import "github.com/gin-gonic/gin"
func CORS() gin.HandlerFunc {
return func(c *gin.Context) {
c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
c.Writer.Header().Set("Access-Control-Allow-Credentials", "true")
c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, accept, origin, Cache-Control, X-Requested-With, x-jwt")
c.Writer.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS, GET, PUT, DELETE")
if c.Request.Method == "OPTIONS" {
c.AbortWithStatus(204)
return
}
c.Next()
}
}

View File

@@ -0,0 +1,41 @@
// internal/middleware/logger.go
package middleware
import (
"strconv"
"time"
"go-server/pkg/logger"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)
// Logger provides lightweight structured logging and response time header
func Logger() gin.HandlerFunc {
return func(c *gin.Context) {
start := time.Now()
c.Next()
latency := time.Since(start)
status := c.Writer.Status()
method := c.Request.Method
path := c.Request.URL.Path
ip := c.ClientIP()
ua := c.Request.UserAgent()
// Expose response time for clients/benchmarks
c.Header("X-Response-Time", strconv.FormatInt(latency.Microseconds(), 10)+"us")
// Structured log (zap is very fast and minimally blocking)
logger.Info("http_request",
zap.Int("status", status),
zap.String("method", method),
zap.String("path", path),
zap.Int64("latency_us", latency.Microseconds()),
zap.String("ip", ip),
zap.String("user_agent", ua),
)
}
}

View File

@@ -0,0 +1,21 @@
package models
import (
"time"
"gorm.io/gorm"
)
type BaseModel struct {
ID ULID `gorm:"primaryKey" json:"id" db:"id" `
CreatedAt time.Time `json:"createdAt" db:"created_at"`
UpdatedAt time.Time `json:"updatedAt" db:"updated_at"`
}
// BeforeCreate is a GORM hook that runs before creating a record
func (b *BaseModel) BeforeCreate(tx *gorm.DB) error {
if b.ID.IsZero() {
b.ID = GenerateULID()
}
return nil
}

View File

@@ -0,0 +1,11 @@
package models
func (Category) TableName() string {
return "categories"
}
type Category struct {
BaseModel
Name string `json:"name" db:"name"`
Description string `json:"description" db:"description"`
}

View File

@@ -0,0 +1,13 @@
package models
func (Nutrient) TableName() string {
return "nutrients"
}
type Nutrient struct {
BaseModel
Name string `json:"name" db:"name"`
Description string `json:"description" db:"description"`
Supplements []Supplement `json:"supplements" db:"supplements" gorm:"many2many:supplement_nutrients;"`
Categories []Category `json:"categories" db:"categories" gorm:"many2many:nutrient_categories;"`
}

View File

@@ -0,0 +1,13 @@
package models
func (NutrientCategory) TableName() string {
return "nutrient_categories"
}
type NutrientCategory struct {
BaseModel
CategoryID ULID `json:"categoryId" db:"category_id"`
NutrientID ULID `json:"nutrientId" db:"nutrient_id"`
Category Category `json:"category" db:"category" gorm:"foreignKey:CategoryID"`
Nutrient Nutrient `json:"nutrient" db:"nutrient" gorm:"foreignKey:NutrientID"`
}

View File

@@ -0,0 +1,21 @@
package models
func (Supplement) TableName() string {
return "supplements"
}
type Supplement struct {
BaseModel
Name string `json:"name" db:"name"`
Description string `json:"description" db:"description"`
Price int `json:"price" db:"price"`
Image string `json:"image" db:"image"`
Nutrients []Nutrient `json:"nutrients" db:"nutrients" gorm:"many2many:supplement_nutrients;"`
SupplementNutrients []SupplementNutrient `json:"supplementNutrients" db:"supplementNutrients" gorm:"foreignKey:SupplementID"`
}
type DailySupplementsOverview struct {
Supplements []*Supplement `json:"supplements"`
Nutrients []*Nutrient `json:"nutrients"`
Overview []*SupplementNutrientOverview `json:"overview"`
}

View File

@@ -0,0 +1,22 @@
package models
func (SupplementNutrient) TableName() string {
return "supplement_nutrients"
}
type SupplementNutrient struct {
BaseModel
SupplementID ULID `json:"supplementId" db:"supplement_id"`
NutrientID ULID `json:"nutrientId" db:"nutrient_id"`
ServingSize string `json:"servingSize" db:"serving_size"`
PerServing string `json:"perServing" db:"per_serving"`
PerServingReferenceIntake string `json:"perServingReferenceIntake" db:"per_serving_reference_intake"`
}
type SupplementNutrientOverview struct {
SupplementID ULID `json:"supplementId" db:"supplement_id"`
NutrientID ULID `json:"nutrientId" db:"nutrient_id"`
ServingSize string `json:"servingSize" db:"serving_size"`
PerServing string `json:"perServing" db:"per_serving"`
PerServingReferenceIntake string `json:"perServingReferenceIntake" db:"per_serving_reference_intake"`
}

View File

@@ -0,0 +1,56 @@
package models
import (
"time"
)
func (Todo) TableName() string {
return "todos"
}
type Todo struct {
BaseModel
UserID ULID `json:"userId" db:"user_id" gorm:"not null"`
Title string `json:"title" db:"title" gorm:"not null"`
Description string `json:"description" db:"description"`
Color string `json:"color" db:"color" gorm:"default:'#3B82F6'"` // Default blue color
IsActive bool `json:"isActive" db:"is_active" gorm:"default:true"`
// Relationships
User User `json:"user" gorm:"foreignKey:UserID"`
Completions []TodoCompletion `json:"completions" gorm:"foreignKey:TodoID"`
}
func (TodoCompletion) TableName() string {
return "todo_completions"
}
type TodoCompletion struct {
BaseModel
TodoID ULID `json:"todoId" db:"todo_id" gorm:"not null"`
UserID ULID `json:"userId" db:"user_id" gorm:"not null"`
CompletedAt time.Time `json:"completedAt" db:"completed_at" gorm:"not null"`
Description string `json:"description" db:"description"` // User's notes about how they completed it
// Relationships
Todo Todo `json:"todo" gorm:"foreignKey:TodoID"`
User User `json:"user" gorm:"foreignKey:UserID"`
}
// TodoWithStats represents a todo with completion statistics
type TodoWithStats struct {
Todo
CurrentStreak int `json:"currentStreak"`
LongestStreak int `json:"longestStreak"`
TotalCompletions int `json:"totalCompletions"`
LastCompletedAt *time.Time `json:"lastCompletedAt"`
CompletedToday bool `json:"completedToday"`
}
// DailyTodoSummary represents todos for a specific day
type DailyTodoSummary struct {
Date time.Time `json:"date"`
Todos []TodoWithStats `json:"todos"`
CompletedCount int `json:"completedCount"`
TotalCount int `json:"totalCount"`
}

View File

@@ -0,0 +1,141 @@
package models
import (
"crypto/rand"
"database/sql/driver"
"fmt"
"time"
"github.com/oklog/ulid/v2"
"gorm.io/gorm"
"gorm.io/gorm/schema"
)
// ULID is a custom type that wraps oklog/ulid/v2.ULID and implements
// GORM's Scanner and Valuer interfaces for database operations
type ULID struct {
ulid.ULID
}
// NewULID creates a new ULID from a ulid.ULID
func NewULID(u ulid.ULID) ULID {
return ULID{ULID: u}
}
// ParseULID parses a string into a ULID
func ParseULID(s string) (ULID, error) {
u, err := ulid.Parse(s)
if err != nil {
return ULID{}, err
}
return ULID{ULID: u}, nil
}
// MustParseULID parses a string into a ULID and panics on error
func MustParseULID(s string) ULID {
u, err := ParseULID(s)
if err != nil {
panic(err)
}
return u
}
// GenerateULID creates a new ULID with the current timestamp
func GenerateULID() ULID {
return ULID{ULID: ulid.MustNew(ulid.Timestamp(time.Now()), rand.Reader)}
}
// GenerateULIDWithTime creates a new ULID with the specified timestamp
func GenerateULIDWithTime(t time.Time) ULID {
return ULID{ULID: ulid.MustNew(ulid.Timestamp(t), rand.Reader)}
}
// Scan implements the sql.Scanner interface for reading from database
func (u *ULID) Scan(value interface{}) error {
if value == nil {
*u = ULID{}
return nil
}
switch v := value.(type) {
case string:
parsed, err := ulid.Parse(v)
if err != nil {
return fmt.Errorf("cannot parse ULID from string: %w", err)
}
u.ULID = parsed
return nil
case []byte:
parsed, err := ulid.Parse(string(v))
if err != nil {
return fmt.Errorf("cannot parse ULID from bytes: %w", err)
}
u.ULID = parsed
return nil
default:
return fmt.Errorf("cannot scan %T into ULID", value)
}
}
// Value implements the driver.Valuer interface for writing to database
func (u ULID) Value() (driver.Value, error) {
if u.ULID == (ulid.ULID{}) {
return nil, nil
}
return u.ULID.String(), nil
}
// GormDataType returns the data type for GORM
func (ULID) GormDataType() string {
return "char(26)"
}
// GormDBDataType returns the database-specific data type for GORM
func (ULID) GormDBDataType(db *gorm.DB, field *schema.Field) string {
switch db.Dialector.Name() {
case "postgres":
return "char(26)"
case "mysql":
return "char(26)"
case "sqlite":
return "text"
default:
return "char(26)"
}
}
// MarshalJSON implements json.Marshaler
func (u ULID) MarshalJSON() ([]byte, error) {
return []byte(`"` + u.ULID.String() + `"`), nil
}
// UnmarshalJSON implements json.Unmarshaler
func (u *ULID) UnmarshalJSON(data []byte) error {
if len(data) < 2 || data[0] != '"' || data[len(data)-1] != '"' {
return fmt.Errorf("invalid JSON string for ULID")
}
str := string(data[1 : len(data)-1])
if str == "" {
*u = ULID{}
return nil
}
parsed, err := ulid.Parse(str)
if err != nil {
return fmt.Errorf("cannot parse ULID from JSON: %w", err)
}
u.ULID = parsed
return nil
}
// String returns the string representation of the ULID
func (u ULID) String() string {
return u.ULID.String()
}
// IsZero returns true if the ULID is zero value
func (u ULID) IsZero() bool {
return u.ULID == ulid.ULID{}
}

25
server/internal/models/user.go Executable file
View File

@@ -0,0 +1,25 @@
package models
import "time"
type PlatformRole string
const (
PlatformRoleUser PlatformRole = "USER"
PlatformRoleAdmin PlatformRole = "ADMIN"
)
func (User) TableName() string {
return "users"
}
type User struct {
ID []byte `gorm:"primaryKey;type:bytea" json:"id" db:"id"`
CreatedAt time.Time `json:"createdAt" db:"created_at"`
UpdatedAt time.Time `json:"updatedAt" db:"updated_at"`
FirstName string `json:"firstName" db:"first_name"`
LastName string `json:"lastName" db:"last_name"`
Email string `gorm:"uniqueIndex;not null" json:"email" db:"email"`
Password string `gorm:"not null" json:"password" db:"password"`
PlatformRole PlatformRole `gorm:"not null" json:"platformRole" db:"platform_role"`
}

View File

@@ -0,0 +1,81 @@
// internal/queue/email_service.go
package queue
import (
"fmt"
"gopkg.in/gomail.v2"
)
// EmailService defines the interface for sending emails
type EmailService interface {
Send(to string, subject string, body string) error
}
// SMTPEmailService implements EmailService using SMTP
type SMTPEmailService struct {
dialer *gomail.Dialer
from string
}
type SMTPConfig struct {
Host string
Port int
Username string
Password string
From string
}
func NewSMTPEmailService(config SMTPConfig) *SMTPEmailService {
dialer := gomail.NewDialer(
config.Host,
config.Port,
config.Username,
config.Password,
)
return &SMTPEmailService{
dialer: dialer,
from: config.From,
}
}
func (s *SMTPEmailService) Send(to string, subject string, body string) error {
m := gomail.NewMessage()
m.SetHeader("From", s.from)
m.SetHeader("To", to)
m.SetHeader("Subject", subject)
m.SetBody("text/html", body)
if err := s.dialer.DialAndSend(m); err != nil {
return fmt.Errorf("failed to send email: %w", err)
}
return nil
}
// MockEmailService implements EmailService for testing
type MockEmailService struct {
SentEmails []MockEmail
}
type MockEmail struct {
To string
Subject string
Body string
}
func NewMockEmailService() *MockEmailService {
return &MockEmailService{
SentEmails: make([]MockEmail, 0),
}
}
func (s *MockEmailService) Send(to string, subject string, body string) error {
s.SentEmails = append(s.SentEmails, MockEmail{
To: to,
Subject: subject,
Body: body,
})
return nil
}

View File

@@ -0,0 +1,88 @@
// internal/queue/worker.go
package queue
import (
"context"
"encoding/json"
"fmt"
"log"
"time"
"github.com/hibiken/asynq"
)
const (
TypeEmailDelivery = "email:deliver"
TypeDataExport = "data:export"
)
type TaskHandler struct {
emailService EmailService
// Add other services needed for tasks
}
func NewTaskHandler(emailService EmailService) *TaskHandler {
return &TaskHandler{
emailService: emailService,
}
}
func NewQueueClient(redisAddr string) *asynq.Client {
return asynq.NewClient(asynq.RedisClientOpt{Addr: redisAddr})
}
func StartWorkerServer(redisAddr string, handler *TaskHandler) {
srv := asynq.NewServer(
asynq.RedisClientOpt{Addr: redisAddr},
asynq.Config{
Concurrency: 10,
Queues: map[string]int{
"critical": 6,
"default": 3,
"low": 1,
},
},
)
mux := asynq.NewServeMux()
mux.HandleFunc(TypeEmailDelivery, handler.HandleEmailDeliveryTask)
mux.HandleFunc(TypeDataExport, handler.HandleDataExportTask)
if err := srv.Run(mux); err != nil {
log.Fatalf("Could not run queue server: %v", err)
}
}
// Task Handlers
func (h *TaskHandler) HandleEmailDeliveryTask(ctx context.Context, t *asynq.Task) error {
var p EmailDeliveryPayload
if err := json.Unmarshal(t.Payload(), &p); err != nil {
return fmt.Errorf("json.Unmarshal failed: %v", err)
}
// Process the email delivery task
return h.emailService.Send(p.To, p.Subject, p.Body)
}
func (h *TaskHandler) HandleDataExportTask(ctx context.Context, t *asynq.Task) error {
var p DataExportPayload
if err := json.Unmarshal(t.Payload(), &p); err != nil {
return fmt.Errorf("json.Unmarshal failed: %v", err)
}
// Process the data export task
return nil
}
// Task Payloads
type EmailDeliveryPayload struct {
To string
Subject string
Body string
}
type DataExportPayload struct {
UserID string
Format string
Timestamp time.Time
}

View File

@@ -0,0 +1,22 @@
package repository
import (
"context"
"go-server/internal/models"
"gorm.io/gorm"
)
type CategoryRepository struct {
db *gorm.DB
}
func NewCategoryRepository(db *gorm.DB) *CategoryRepository {
return &CategoryRepository{db: db}
}
func (r *CategoryRepository) GetAll(ctx context.Context) ([]models.Category, error) {
var categories []models.Category
err := r.db.WithContext(ctx).Find(&categories).Error
return categories, err
}

View File

@@ -0,0 +1,187 @@
package repository
import (
"context"
"go-server/internal/models"
"gorm.io/gorm"
)
type DailyOverviewRepository struct {
db *gorm.DB
}
func NewDailyOverviewRepository(db *gorm.DB) *DailyOverviewRepository {
return &DailyOverviewRepository{db: db}
}
// NutrientTotal represents the aggregated total for a nutrient across all supplements
type NutrientTotal struct {
NutrientID models.ULID `json:"nutrientId" db:"nutrient_id"`
NutrientName string `json:"nutrientName" db:"nutrient_name"`
Description string `json:"description" db:"description"`
TotalAmount string `json:"totalAmount" db:"total_amount"`
Unit string `json:"unit" db:"unit"`
Categories []string `json:"categories" db:"categories"`
}
// SupplementNutrientDetails represents detailed breakdown by supplement
type SupplementNutrientDetails struct {
SupplementID models.ULID `json:"supplementId" db:"supplement_id"`
SupplementName string `json:"supplementName" db:"supplement_name"`
NutrientID models.ULID `json:"nutrientId" db:"nutrient_id"`
NutrientName string `json:"nutrientName" db:"nutrient_name"`
Amount string `json:"amount" db:"amount"`
Unit string `json:"unit" db:"unit"`
ServingSize string `json:"servingSize" db:"serving_size"`
ReferenceIntake string `json:"referenceIntake" db:"reference_intake"`
}
// GetNutrientTotals returns aggregated nutrient totals across all supplements
// This is perfect for a daily overview where you want to see total intake
func (r *DailyOverviewRepository) GetNutrientTotals(ctx context.Context) ([]NutrientTotal, error) {
var results []NutrientTotal
query := `
SELECT
n.id as nutrient_id,
n.name as nutrient_name,
n.description,
sn.per_serving_reference_intake,
-- For now, we'll concatenate amounts (later we can parse and sum numeric values)
STRING_AGG(sn.per_serving, ' + ') as total_amount,
-- Extract unit from first entry (assumption: same nutrient has same unit)
SPLIT_PART(MIN(sn.per_serving), ' ', 2) as unit,
-- Get all categories for this nutrient
ARRAY_AGG(DISTINCT c.name) as categories
FROM nutrients n
LEFT JOIN supplement_nutrients sn ON n.id = sn.nutrient_id
LEFT JOIN supplements s ON sn.supplement_id = s.id
LEFT JOIN nutrient_categories nc ON n.id = nc.nutrient_id
LEFT JOIN categories c ON nc.category_id = c.id
WHERE sn.id IS NOT NULL -- Only nutrients that are in supplements
GROUP BY n.id, n.name, n.description, sn.per_serving_reference_intake
ORDER BY n.name
`
err := r.db.WithContext(ctx).Raw(query).Scan(&results).Error
return results, err
}
// GetSupplementBreakdown returns detailed breakdown of nutrients by supplement
func (r *DailyOverviewRepository) GetSupplementBreakdown(ctx context.Context) ([]SupplementNutrientDetails, error) {
var results []SupplementNutrientDetails
query := `
SELECT
s.id as supplement_id,
s.name as supplement_name,
n.id as nutrient_id,
n.name as nutrient_name,
sn.per_serving as amount,
SPLIT_PART(sn.per_serving, ' ', 2) as unit,
sn.serving_size,
sn.per_serving_reference_intake as reference_intake
FROM supplements s
JOIN supplement_nutrients sn ON s.id = sn.supplement_id
JOIN nutrients n ON sn.nutrient_id = n.id
ORDER BY s.name, n.name
`
err := r.db.WithContext(ctx).Raw(query).Scan(&results).Error
return results, err
}
// GetNutrientsByCategory returns nutrients grouped by category with totals
func (r *DailyOverviewRepository) GetNutrientsByCategory(ctx context.Context) (map[string][]NutrientTotal, error) {
var results []struct {
CategoryName string `db:"category_name"`
CategoryID models.ULID `db:"category_id"`
NutrientID models.ULID `db:"nutrient_id"`
NutrientName string `db:"nutrient_name"`
Description string `db:"description"`
TotalAmount string `db:"total_amount"`
Unit string `db:"unit"`
}
query := `
SELECT
c.name as category_name,
c.id as category_id,
n.id as nutrient_id,
n.name as nutrient_name,
n.description,
STRING_AGG(sn.per_serving, ' + ') as total_amount,
SPLIT_PART(MIN(sn.per_serving), ' ', 2) as unit
FROM categories c
JOIN nutrient_categories nc ON c.id = nc.category_id
JOIN nutrients n ON nc.nutrient_id = n.id
LEFT JOIN supplement_nutrients sn ON n.id = sn.nutrient_id
WHERE sn.id IS NOT NULL
GROUP BY c.name, n.id, n.name, n.description, c.id
ORDER BY c.name, n.name
`
err := r.db.WithContext(ctx).Raw(query).Scan(&results).Error
if err != nil {
return nil, err
}
// Group by category
categoryMap := make(map[string][]NutrientTotal)
for _, result := range results {
nutrient := NutrientTotal{
NutrientID: result.NutrientID,
NutrientName: result.NutrientName,
Description: result.Description,
TotalAmount: result.TotalAmount,
Unit: result.Unit,
Categories: []string{result.CategoryID.String()},
}
categoryMap[result.CategoryName] = append(categoryMap[result.CategoryName], nutrient)
}
return categoryMap, nil
}
// ExecuteRawQuery allows executing arbitrary SQL queries and scanning into any struct
// This gives you full flexibility for custom queries
func (r *DailyOverviewRepository) ExecuteRawQuery(ctx context.Context, query string, dest interface{}, args ...interface{}) error {
return r.db.WithContext(ctx).Raw(query, args...).Scan(dest).Error
}
// ExecuteRawQueryWithResult executes a raw query and returns the result as a map
// Useful for dynamic queries where you don't know the structure ahead of time
func (r *DailyOverviewRepository) ExecuteRawQueryWithResult(ctx context.Context, query string, args ...interface{}) ([]map[string]interface{}, error) {
rows, err := r.db.WithContext(ctx).Raw(query, args...).Rows()
if err != nil {
return nil, err
}
defer rows.Close()
columns, err := rows.Columns()
if err != nil {
return nil, err
}
var results []map[string]interface{}
for rows.Next() {
values := make([]interface{}, len(columns))
valuePtrs := make([]interface{}, len(columns))
for i := range columns {
valuePtrs[i] = &values[i]
}
if err := rows.Scan(valuePtrs...); err != nil {
return nil, err
}
result := make(map[string]interface{})
for i, col := range columns {
result[col] = values[i]
}
results = append(results, result)
}
return results, nil
}

View File

@@ -0,0 +1,28 @@
package repository
import (
"context"
"go-server/internal/models"
"gorm.io/gorm"
)
type NutrientRepository struct {
db *gorm.DB
}
func NewNutrientRepository(db *gorm.DB) *NutrientRepository {
return &NutrientRepository{db: db}
}
func (r *NutrientRepository) GetAll(ctx context.Context) ([]*models.Nutrient, error) {
var nutrients []*models.Nutrient
err := r.db.WithContext(ctx).
Preload("Categories").
Find(&nutrients).
Error
if err != nil {
return nil, err
}
return nutrients, nil
}

View File

@@ -0,0 +1,57 @@
package repository
import (
"context"
"go-server/internal/models"
"gorm.io/gorm"
)
type SupplementRepository struct {
db *gorm.DB
}
func NewSupplementRepository(db *gorm.DB) *SupplementRepository {
return &SupplementRepository{db: db}
}
func (r *SupplementRepository) GetAll(ctx context.Context) ([]*models.Supplement, error) {
var supplements []*models.Supplement
err := r.db.WithContext(ctx).
Preload("Nutrients").
Preload("SupplementNutrients").
Find(&supplements).
Error
if err != nil {
return nil, err
}
return supplements, nil
}
func (r *SupplementRepository) GetById(ctx context.Context, id string) (*models.Supplement, error) {
var supplement *models.Supplement
err := r.db.WithContext(ctx).
Preload("Nutrients").
Preload("SupplementNutrients").
First(&supplement, "id = ?", id).
Error
if err != nil {
return nil, err
}
return supplement, nil
}
func (r *SupplementRepository) GetDailySupplementsOverview(ctx context.Context) ([]*models.SupplementNutrientOverview, error) {
var supplementNutrientOverview []*models.SupplementNutrientOverview
err := r.db.WithContext(ctx).
Table("supplements").
Select("supplements.id as supplement_id, supplements.name as supplement_name, supplements.description as supplement_description, supplement_nutrients.serving_size as serving_size, supplement_nutrients.per_serving as per_serving, supplement_nutrients.per_serving_reference_intake as per_serving_reference_intake, nutrients.id as nutrient_id, nutrients.name as nutrient_name, nutrients.description as nutrient_description").
Joins("INNER JOIN supplement_nutrients ON supplements.id = supplement_nutrients.supplement_id").
Joins("JOIN nutrients ON supplement_nutrients.nutrient_id = nutrients.id").
Find(&supplementNutrientOverview).
Error
if err != nil {
return nil, err
}
return supplementNutrientOverview, nil
}

View File

@@ -0,0 +1,247 @@
package repository
import (
"context"
"go-server/internal/models"
"time"
"gorm.io/gorm"
)
type TodoRepository struct {
db *gorm.DB
}
func NewTodoRepository(db *gorm.DB) *TodoRepository {
return &TodoRepository{db: db}
}
// CreateTodo creates a new todo for a user
func (r *TodoRepository) CreateTodo(ctx context.Context, todo *models.Todo) error {
return r.db.WithContext(ctx).Create(todo).Error
}
// GetTodosByUserID gets all active todos for a user
func (r *TodoRepository) GetTodosByUserID(ctx context.Context, userID models.ULID) ([]models.Todo, error) {
var todos []models.Todo
err := r.db.WithContext(ctx).
Where("user_id = ? AND is_active = ?", userID, true).
Order("created_at ASC").
Find(&todos).Error
return todos, err
}
// GetTodoByID gets a todo by ID and user ID (for security)
func (r *TodoRepository) GetTodoByID(ctx context.Context, todoID, userID models.ULID) (*models.Todo, error) {
var todo models.Todo
err := r.db.WithContext(ctx).
Where("id = ? AND user_id = ?", todoID, userID).
First(&todo).Error
if err != nil {
return nil, err
}
return &todo, nil
}
// UpdateTodo updates a todo
func (r *TodoRepository) UpdateTodo(ctx context.Context, todo *models.Todo) error {
return r.db.WithContext(ctx).Save(todo).Error
}
// DeleteTodo soft deletes a todo (sets is_active to false)
func (r *TodoRepository) DeleteTodo(ctx context.Context, todoID, userID models.ULID) error {
return r.db.WithContext(ctx).
Model(&models.Todo{}).
Where("id = ? AND user_id = ?", todoID, userID).
Update("is_active", false).Error
}
// CompleteTodo creates a completion record for a todo
func (r *TodoRepository) CompleteTodo(ctx context.Context, completion *models.TodoCompletion) error {
return r.db.WithContext(ctx).Create(completion).Error
}
// GetTodayCompletions gets all completions for today for a user
func (r *TodoRepository) GetTodayCompletions(ctx context.Context, userID models.ULID) ([]models.TodoCompletion, error) {
now := time.Now()
startOfDay := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
endOfDay := startOfDay.Add(24 * time.Hour)
var completions []models.TodoCompletion
err := r.db.WithContext(ctx).
Preload("Todo").
Where("user_id = ? AND completed_at >= ? AND completed_at < ?", userID, startOfDay, endOfDay).
Order("completed_at DESC").
Find(&completions).Error
return completions, err
}
// GetCompletionsByDateRange gets completions for a date range
func (r *TodoRepository) GetCompletionsByDateRange(ctx context.Context, userID models.ULID, startDate, endDate time.Time) ([]models.TodoCompletion, error) {
var completions []models.TodoCompletion
err := r.db.WithContext(ctx).
Preload("Todo").
Where("user_id = ? AND completed_at >= ? AND completed_at < ?", userID, startDate, endDate).
Order("completed_at DESC").
Find(&completions).Error
return completions, err
}
// GetTodoWithStats gets todos with completion statistics using raw SQL for better performance
func (r *TodoRepository) GetTodosWithStats(ctx context.Context, userID models.ULID) ([]models.TodoWithStats, error) {
var results []models.TodoWithStats
query := `
WITH todo_stats AS (
SELECT
t.id,
t.user_id,
t.title,
t.description,
t.color,
t.is_active,
t.created_at,
t.updated_at,
COUNT(tc.id) as total_completions,
MAX(tc.completed_at) as last_completed_at,
CASE
WHEN MAX(tc.completed_at) >= CURRENT_DATE
THEN true
ELSE false
END as completed_today
FROM todos t
LEFT JOIN todo_completions tc ON t.id = tc.todo_id
WHERE t.user_id = ? AND t.is_active = true
GROUP BY t.id, t.user_id, t.title, t.description, t.color, t.is_active, t.created_at, t.updated_at
),
streak_calc AS (
SELECT
ts.*,
COALESCE(
(SELECT COUNT(*)
FROM generate_series(
CURRENT_DATE - INTERVAL '365 days',
CURRENT_DATE,
INTERVAL '1 day'
) AS date_series(date)
WHERE EXISTS (
SELECT 1 FROM todo_completions tc2
WHERE tc2.todo_id = ts.id
AND DATE(tc2.completed_at) = date_series.date
)
AND date_series.date <= CURRENT_DATE
AND NOT EXISTS (
SELECT 1 FROM generate_series(
date_series.date + INTERVAL '1 day',
CURRENT_DATE,
INTERVAL '1 day'
) AS gap_check(gap_date)
WHERE NOT EXISTS (
SELECT 1 FROM todo_completions tc3
WHERE tc3.todo_id = ts.id
AND DATE(tc3.completed_at) = gap_check.gap_date
)
)
), 0
) as current_streak
FROM todo_stats ts
)
SELECT
sc.*,
COALESCE(
(SELECT MAX(streak_length)
FROM (
SELECT COUNT(*) as streak_length
FROM (
SELECT
DATE(tc.completed_at) as completion_date,
ROW_NUMBER() OVER (ORDER BY DATE(tc.completed_at)) as rn,
DATE(tc.completed_at) - INTERVAL '1 day' * ROW_NUMBER() OVER (ORDER BY DATE(tc.completed_at)) as streak_group
FROM todo_completions tc
WHERE tc.todo_id = sc.id
) grouped
GROUP BY streak_group
) streaks), 0
) as longest_streak
FROM streak_calc sc
ORDER BY sc.created_at ASC
`
rows, err := r.db.WithContext(ctx).Raw(query, userID).Rows()
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var result models.TodoWithStats
var lastCompletedAt *time.Time
err := rows.Scan(
&result.ID,
&result.UserID,
&result.Title,
&result.Description,
&result.Color,
&result.IsActive,
&result.CreatedAt,
&result.UpdatedAt,
&result.TotalCompletions,
&lastCompletedAt,
&result.CompletedToday,
&result.CurrentStreak,
&result.LongestStreak,
)
if err != nil {
return nil, err
}
result.LastCompletedAt = lastCompletedAt
results = append(results, result)
}
return results, nil
}
// CheckTodoCompletedToday checks if a todo was completed today
func (r *TodoRepository) CheckTodoCompletedToday(ctx context.Context, todoID, userID models.ULID) (bool, error) {
now := time.Now()
startOfDay := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
endOfDay := startOfDay.Add(24 * time.Hour)
var count int64
err := r.db.WithContext(ctx).
Model(&models.TodoCompletion{}).
Where("todo_id = ? AND user_id = ? AND completed_at >= ? AND completed_at < ?",
todoID, userID, startOfDay, endOfDay).
Count(&count).Error
return count > 0, err
}
// GetActivityLog gets activity log with pagination
func (r *TodoRepository) GetActivityLog(ctx context.Context, userID models.ULID, limit, offset int) ([]models.TodoCompletion, error) {
var completions []models.TodoCompletion
err := r.db.WithContext(ctx).
Preload("Todo").
Where("user_id = ?", userID).
Order("completed_at DESC").
Limit(limit).
Offset(offset).
Find(&completions).Error
return completions, err
}
// GetActivityLogByDate gets activity log for a specific date
func (r *TodoRepository) GetActivityLogByDate(ctx context.Context, userID models.ULID, date time.Time) ([]models.TodoCompletion, error) {
startOfDay := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location())
endOfDay := startOfDay.Add(24 * time.Hour)
var completions []models.TodoCompletion
err := r.db.WithContext(ctx).
Preload("Todo").
Where("user_id = ? AND completed_at >= ? AND completed_at < ?", userID, startOfDay, endOfDay).
Order("completed_at DESC").
Find(&completions).Error
return completions, err
}

View File

@@ -0,0 +1,51 @@
package repository
import (
"context"
"errors"
"go-server/internal/models"
"github.com/google/uuid"
"gorm.io/gorm"
)
type UserRepository struct {
db *gorm.DB
}
func NewUserRepository(db *gorm.DB) *UserRepository {
return &UserRepository{db: db}
}
func (r *UserRepository) Create(ctx context.Context, user *models.User) error {
return r.db.WithContext(ctx).Create(user).Error
}
func (r *UserRepository) GetByID(ctx context.Context, id uuid.UUID) (*models.User, error) {
var user models.User
if err := r.db.WithContext(ctx).First(&user, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &user, nil
}
func (r *UserRepository) Update(ctx context.Context, user *models.User) error {
return r.db.WithContext(ctx).Save(user).Error
}
func (r *UserRepository) Delete(ctx context.Context, id uuid.UUID) error {
return r.db.WithContext(ctx).Delete(&models.User{}, id).Error
}
func (r *UserRepository) GetAll(ctx context.Context) ([]*models.User, error) {
var users []*models.User
err := r.db.WithContext(ctx).Find(&users).Error
if err != nil {
return nil, err
}
return users, nil
}

View File

@@ -0,0 +1,143 @@
package routes
import (
"fmt"
"net/http"
handlers "go-server/internal/handlers"
"go-server/internal/middleware"
"go-server/internal/repository"
"go-server/internal/service"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// Route defines the structure for dynamic routing
type Route struct {
Method string
Path string
HandlerFunc gin.HandlerFunc
IsAuthRequired bool
}
// Controller defines the structure for a controller with routes
type Controller struct {
Routes []Route
}
// SetupRouter dynamically sets up routes
func SetupRouter(db *gorm.DB) *gin.Engine {
gin.SetMode(gin.ReleaseMode)
r := gin.New()
r.Use(middleware.CORS())
r.Use(middleware.Logger())
r.Use(gin.Recovery())
// Serve simple text at "/"
r.GET("/", func(c *gin.Context) {
c.String(http.StatusOK, "Hello")
})
// Custom 404 handler
r.NoRoute(func(c *gin.Context) {
c.String(http.StatusNotFound, "Not Found")
})
// Initialize Repositories
userRepo := repository.NewUserRepository(db)
nutrientRepo := repository.NewNutrientRepository(db)
supplementRepo := repository.NewSupplementRepository(db)
dailyOverviewRepo := repository.NewDailyOverviewRepository(db)
categoryRepo := repository.NewCategoryRepository(db)
todoRepo := repository.NewTodoRepository(db)
// Initialize Services
userService := service.NewUserService(userRepo)
nutrientService := service.NewNutrientService(nutrientRepo)
supplementService := service.NewSupplementService(supplementRepo, nutrientRepo)
dailyOverviewService := service.NewDailyOverviewService(dailyOverviewRepo)
categoryService := service.NewCategoryService(categoryRepo)
todoService := service.NewTodoService(todoRepo)
// Initialize Controllers
adminController := handlers.NewAdminController(userService)
nutrientController := handlers.NewNutrientController(nutrientService)
supplementController := handlers.NewSupplementController(supplementService)
dailyOverviewController := handlers.NewDailyOverviewController(dailyOverviewService)
categoryController := handlers.NewCategoryController(categoryService)
todoController := handlers.NewTodoController(todoService)
skipAuth := true
// Define controllers and their routes
controllers := map[string]Controller{
"admin": {
Routes: []Route{
{"GET", "/user/get-all", adminController.GetUsers, true},
{"DELETE", "/user/:id", adminController.DeleteUser, true},
{"POST", "/user/:id/change-password", adminController.ChangePassword, true},
{"POST", "/user", adminController.CreateUser, true},
},
},
"nutrient": {
Routes: []Route{
{"GET", "/get-all", nutrientController.GetAll, !skipAuth},
},
},
"supplement": {
Routes: []Route{
{"GET", "/get-all", supplementController.GetAll, !skipAuth},
{"GET", "/get-daily-supplements-overview", supplementController.GetDailySupplementsOverview, !skipAuth},
},
},
"daily-overview": {
Routes: []Route{
{"GET", "/overview", dailyOverviewController.GetDailyOverview, !skipAuth}, // Complete overview
{"GET", "/totals", dailyOverviewController.GetNutrientTotals, !skipAuth}, // Just nutrient totals
{"GET", "/breakdown", dailyOverviewController.GetSupplementBreakdown, !skipAuth}, // Supplement breakdown
{"POST", "/query", dailyOverviewController.ExecuteCustomQuery, !skipAuth}, // Custom SQL queries
},
},
"category": {
Routes: []Route{
{"GET", "/get-all", categoryController.GetAll, !skipAuth},
},
},
"todo": {
Routes: []Route{
{"POST", "/create", todoController.CreateTodo, !skipAuth}, // Create new todo
{"GET", "/list", todoController.GetTodos, !skipAuth}, // Get all todos with stats
{"GET", "/today", todoController.GetTodaysSummary, !skipAuth}, // Get today's summary
{"PUT", "/:id", todoController.UpdateTodo, !skipAuth}, // Update todo
{"DELETE", "/:id", todoController.DeleteTodo, !skipAuth}, // Delete todo
{"POST", "/:id/complete", todoController.CompleteTodo, !skipAuth}, // Complete todo
{"GET", "/activity", todoController.GetActivityLog, !skipAuth}, // Get activity log
{"GET", "/activity/:date", todoController.GetActivityLogByDate, !skipAuth}, // Get activity for date
{"GET", "/weekly", todoController.GetWeeklySummary, !skipAuth}, // Get weekly summary
},
},
}
// Register all routes dynamically
api := r.Group("/api")
apiPublic := api.Group("")
apiAuth := api.Group("")
apiAuth.Use(middleware.AuthRequired())
for key, controller := range controllers {
for _, route := range controller.Routes {
path := fmt.Sprintf("/%s%s", key, route.Path)
fmt.Println("Route registered", route.Method, path)
if route.IsAuthRequired {
apiAuth.Handle(route.Method, path, route.HandlerFunc)
} else {
apiPublic.Handle(route.Method, path, route.HandlerFunc)
}
}
}
return r
}

View File

@@ -0,0 +1,37 @@
package server
import (
"context"
"fmt"
"net/http"
"time"
"github.com/gin-gonic/gin"
)
type Server struct {
engine *gin.Engine
http *http.Server
}
func NewServer(engine *gin.Engine) *Server {
return &Server{
engine: engine,
http: &http.Server{
Handler: engine,
ReadTimeout: 5 * time.Second,
WriteTimeout: 10 * time.Second,
},
}
}
func (s *Server) Start(port string) error {
s.http.Addr = fmt.Sprintf(":%s", port)
fmt.Println("Server started on port", port)
return s.http.ListenAndServe()
}
func (s *Server) Shutdown(ctx context.Context) error {
fmt.Println("Shutting down server...")
return s.http.Shutdown(ctx)
}

View File

@@ -0,0 +1,19 @@
package service
import (
"context"
"go-server/internal/models"
"go-server/internal/repository"
)
type CategoryService struct {
categoryRepo *repository.CategoryRepository
}
func NewCategoryService(categoryRepo *repository.CategoryRepository) *CategoryService {
return &CategoryService{categoryRepo: categoryRepo}
}
func (s *CategoryService) GetAll(ctx context.Context) ([]models.Category, error) {
return s.categoryRepo.GetAll(ctx)
}

View File

@@ -0,0 +1,86 @@
package service
import (
"context"
"go-server/internal/repository"
)
type DailyOverviewService struct {
dailyRepo *repository.DailyOverviewRepository
}
func NewDailyOverviewService(dailyRepo *repository.DailyOverviewRepository) *DailyOverviewService {
return &DailyOverviewService{
dailyRepo: dailyRepo,
}
}
// DailyNutrientSummary represents the complete daily overview
type DailyNutrientSummary struct {
NutrientTotals []repository.NutrientTotal `json:"nutrientTotals"`
ByCategory map[string][]repository.NutrientTotal `json:"byCategory"`
SupplementBreakdown []repository.SupplementNutrientDetails `json:"supplementBreakdown"`
Summary DailySummaryStats `json:"summary"`
}
type DailySummaryStats struct {
TotalNutrients int `json:"totalNutrients"`
TotalSupplements int `json:"totalSupplements"`
CategoriesCount int `json:"categoriesCount"`
}
// GetDailyOverview returns a comprehensive overview of daily nutrient intake
func (s *DailyOverviewService) GetDailyOverview(ctx context.Context) (*DailyNutrientSummary, error) {
// Get nutrient totals
totals, err := s.dailyRepo.GetNutrientTotals(ctx)
if err != nil {
return nil, err
}
// Get breakdown by category
byCategory, err := s.dailyRepo.GetNutrientsByCategory(ctx)
if err != nil {
return nil, err
}
// Get supplement breakdown
breakdown, err := s.dailyRepo.GetSupplementBreakdown(ctx)
if err != nil {
return nil, err
}
// Calculate summary stats
supplementMap := make(map[string]bool)
for _, item := range breakdown {
supplementMap[item.SupplementName] = true
}
summary := DailySummaryStats{
TotalNutrients: len(totals),
TotalSupplements: len(supplementMap),
CategoriesCount: len(byCategory),
}
return &DailyNutrientSummary{
NutrientTotals: totals,
ByCategory: byCategory,
SupplementBreakdown: breakdown,
Summary: summary,
}, nil
}
// GetNutrientTotals returns just the aggregated totals
func (s *DailyOverviewService) GetNutrientTotals(ctx context.Context) ([]repository.NutrientTotal, error) {
return s.dailyRepo.GetNutrientTotals(ctx)
}
// GetSupplementBreakdown returns detailed breakdown by supplement
func (s *DailyOverviewService) GetSupplementBreakdown(ctx context.Context) ([]repository.SupplementNutrientDetails, error) {
return s.dailyRepo.GetSupplementBreakdown(ctx)
}
// ExecuteCustomQuery allows executing custom SQL queries
// This gives you maximum flexibility for complex analytics
func (s *DailyOverviewService) ExecuteCustomQuery(ctx context.Context, query string, args ...interface{}) ([]map[string]interface{}, error) {
return s.dailyRepo.ExecuteRawQueryWithResult(ctx, query, args...)
}

View File

@@ -0,0 +1,19 @@
package service
import (
"context"
"go-server/internal/models"
"go-server/internal/repository"
)
type NutrientService struct {
repo *repository.NutrientRepository
}
func NewNutrientService(repo *repository.NutrientRepository) *NutrientService {
return &NutrientService{repo: repo}
}
func (s *NutrientService) GetAll(ctx context.Context) ([]*models.Nutrient, error) {
return s.repo.GetAll(ctx)
}

View File

@@ -0,0 +1,43 @@
package service
import (
"context"
"go-server/internal/models"
"go-server/internal/repository"
)
type SupplementService struct {
repo *repository.SupplementRepository
nutrientRepo *repository.NutrientRepository
}
func NewSupplementService(repo *repository.SupplementRepository, nutrientRepo *repository.NutrientRepository) *SupplementService {
return &SupplementService{repo: repo, nutrientRepo: nutrientRepo}
}
func (s *SupplementService) GetAll(ctx context.Context) ([]*models.Supplement, error) {
return s.repo.GetAll(ctx)
}
func (s *SupplementService) GetDailySupplementsOverview(ctx context.Context) (*models.DailySupplementsOverview, error) {
supplements, err := s.repo.GetAll(ctx)
if err != nil {
return nil, err
}
nutrients, err := s.nutrientRepo.GetAll(ctx)
if err != nil {
return nil, err
}
overview, err := s.repo.GetDailySupplementsOverview(ctx)
if err != nil {
return nil, err
}
return &models.DailySupplementsOverview{
Supplements: supplements,
Nutrients: nutrients,
Overview: overview,
}, nil
}

View File

@@ -0,0 +1,181 @@
package service
import (
"context"
"errors"
"go-server/internal/models"
"go-server/internal/repository"
"time"
)
type TodoService struct {
todoRepo *repository.TodoRepository
}
func NewTodoService(todoRepo *repository.TodoRepository) *TodoService {
return &TodoService{
todoRepo: todoRepo,
}
}
// CreateTodoRequest represents the request to create a new todo
type CreateTodoRequest struct {
Title string `json:"title" validate:"required,min=1,max=200"`
Description string `json:"description" validate:"max=1000"`
Color string `json:"color" validate:"required,hexcolor"`
}
// UpdateTodoRequest represents the request to update a todo
type UpdateTodoRequest struct {
Title string `json:"title" validate:"required,min=1,max=200"`
Description string `json:"description" validate:"max=1000"`
Color string `json:"color" validate:"required,hexcolor"`
}
// CompleteTodoRequest represents the request to complete a todo
type CompleteTodoRequest struct {
Description string `json:"description" validate:"max=1000"`
}
// CreateTodo creates a new todo for a user
func (s *TodoService) CreateTodo(ctx context.Context, userID models.ULID, req CreateTodoRequest) (*models.Todo, error) {
todo := &models.Todo{
UserID: userID,
Title: req.Title,
Description: req.Description,
Color: req.Color,
IsActive: true,
}
err := s.todoRepo.CreateTodo(ctx, todo)
if err != nil {
return nil, err
}
return todo, nil
}
// GetTodosWithStats gets all todos for a user with completion statistics
func (s *TodoService) GetTodosWithStats(ctx context.Context, userID models.ULID) ([]models.TodoWithStats, error) {
return s.todoRepo.GetTodosWithStats(ctx, userID)
}
// GetTodoByID gets a todo by ID (with user verification)
func (s *TodoService) GetTodoByID(ctx context.Context, todoID, userID models.ULID) (*models.Todo, error) {
return s.todoRepo.GetTodoByID(ctx, todoID, userID)
}
// UpdateTodo updates a todo
func (s *TodoService) UpdateTodo(ctx context.Context, todoID, userID models.ULID, req UpdateTodoRequest) (*models.Todo, error) {
todo, err := s.todoRepo.GetTodoByID(ctx, todoID, userID)
if err != nil {
return nil, err
}
todo.Title = req.Title
todo.Description = req.Description
todo.Color = req.Color
err = s.todoRepo.UpdateTodo(ctx, todo)
if err != nil {
return nil, err
}
return todo, nil
}
// DeleteTodo deletes (deactivates) a todo
func (s *TodoService) DeleteTodo(ctx context.Context, todoID, userID models.ULID) error {
return s.todoRepo.DeleteTodo(ctx, todoID, userID)
}
// CompleteTodo marks a todo as completed for today
func (s *TodoService) CompleteTodo(ctx context.Context, todoID, userID models.ULID, req CompleteTodoRequest) error {
// First check if the todo exists and belongs to the user
todo, err := s.todoRepo.GetTodoByID(ctx, todoID, userID)
if err != nil {
return err
}
// Check if already completed today
completedToday, err := s.todoRepo.CheckTodoCompletedToday(ctx, todoID, userID)
if err != nil {
return err
}
if completedToday {
return errors.New("todo already completed today")
}
// Create completion record
completion := &models.TodoCompletion{
TodoID: todo.ID,
UserID: userID,
CompletedAt: time.Now(),
Description: req.Description,
}
return s.todoRepo.CompleteTodo(ctx, completion)
}
// GetTodaysSummary gets today's todo summary
func (s *TodoService) GetTodaysSummary(ctx context.Context, userID models.ULID) (*models.DailyTodoSummary, error) {
todos, err := s.todoRepo.GetTodosWithStats(ctx, userID)
if err != nil {
return nil, err
}
completedCount := 0
for _, todo := range todos {
if todo.CompletedToday {
completedCount++
}
}
summary := &models.DailyTodoSummary{
Date: time.Now(),
Todos: todos,
CompletedCount: completedCount,
TotalCount: len(todos),
}
return summary, nil
}
// GetActivityLog gets the user's activity log with pagination
func (s *TodoService) GetActivityLog(ctx context.Context, userID models.ULID, limit, offset int) ([]models.TodoCompletion, error) {
if limit <= 0 {
limit = 50 // Default limit
}
if limit > 200 {
limit = 200 // Max limit
}
return s.todoRepo.GetActivityLog(ctx, userID, limit, offset)
}
// GetActivityLogByDate gets activity log for a specific date
func (s *TodoService) GetActivityLogByDate(ctx context.Context, userID models.ULID, date time.Time) ([]models.TodoCompletion, error) {
return s.todoRepo.GetActivityLogByDate(ctx, userID, date)
}
// GetWeeklySummary gets a summary of the past 7 days
func (s *TodoService) GetWeeklySummary(ctx context.Context, userID models.ULID) (map[string][]models.TodoCompletion, error) {
endDate := time.Now().Add(24 * time.Hour) // Include today
startDate := endDate.Add(-7 * 24 * time.Hour) // 7 days ago
completions, err := s.todoRepo.GetCompletionsByDateRange(ctx, userID, startDate, endDate)
if err != nil {
return nil, err
}
// Group by date
dailyCompletions := make(map[string][]models.TodoCompletion)
for _, completion := range completions {
dateKey := completion.CompletedAt.Format("2006-01-02")
dailyCompletions[dateKey] = append(dailyCompletions[dateKey], completion)
}
return dailyCompletions, nil
}

View File

@@ -0,0 +1,138 @@
package service
import (
"context"
"errors"
"fmt"
"go-server/internal/models"
"go-server/internal/repository"
"github.com/google/uuid"
"golang.org/x/crypto/bcrypt"
)
type UserService struct {
repo *repository.UserRepository
}
type CreateUserInput struct {
Email string
Password string
FirstName string
LastName string
}
type UpdateUserInput struct {
FirstName *string
LastName *string
}
type ChangePasswordInput struct {
Password string
}
func NewUserService(repo *repository.UserRepository) *UserService {
return &UserService{repo: repo}
}
func (s *UserService) GetByID(ctx context.Context, id uuid.UUID) (*models.User, error) {
return s.repo.GetByID(ctx, id)
}
func (s *UserService) GetAll(ctx context.Context) ([]*models.User, error) {
return s.repo.GetAll(ctx)
}
func (s *UserService) Create(ctx context.Context, input CreateUserInput) (*models.User, error) {
hashedPassword, err := hashPassword(input.Password)
if err != nil {
return nil, fmt.Errorf("password hashing failed: %w", err)
}
user := &models.User{
Email: input.Email,
Password: hashedPassword,
FirstName: input.FirstName,
LastName: input.LastName,
PlatformRole: models.PlatformRoleUser,
}
if err := s.repo.Create(ctx, user); err != nil {
return nil, fmt.Errorf("failed to create user: %w", err)
}
return user, nil
}
func (s *UserService) Update(ctx context.Context, id uuid.UUID, input UpdateUserInput) (*models.User, error) {
user, err := s.repo.GetByID(ctx, id)
if err != nil {
return nil, fmt.Errorf("failed to fetch user: %w", err)
}
if user == nil {
return nil, errors.New("user not found")
}
if input.FirstName != nil {
user.FirstName = *input.FirstName
}
if input.LastName != nil {
user.LastName = *input.LastName
}
if err := s.repo.Update(ctx, user); err != nil {
return nil, fmt.Errorf("failed to update user: %w", err)
}
return user, nil
}
func (s *UserService) UpdateLastLogin(ctx context.Context, id uuid.UUID) error {
user, err := s.repo.GetByID(ctx, id)
if err != nil {
return fmt.Errorf("failed to fetch user: %w", err)
}
if user == nil {
return errors.New("user not found")
}
if err := s.repo.Update(ctx, user); err != nil {
return fmt.Errorf("failed to update last login: %w", err)
}
return nil
}
func (s *UserService) Delete(ctx context.Context, id uuid.UUID) error {
return s.repo.Delete(ctx, id)
}
func (s *UserService) ChangePassword(ctx context.Context, id uuid.UUID, input ChangePasswordInput) error {
hashedPassword, err := hashPassword(input.Password)
if err != nil {
return fmt.Errorf("password hashing failed: %w", err)
}
user, err := s.repo.GetByID(ctx, id)
if err != nil {
return fmt.Errorf("failed to fetch user: %w", err)
}
if user == nil {
return errors.New("user not found")
}
user.Password = hashedPassword
if err := s.repo.Update(ctx, user); err != nil {
return fmt.Errorf("failed to update user: %w", err)
}
return nil
}
// hashPassword generates a bcrypt hash for a given password.
func hashPassword(password string) (string, error) {
hashed, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return "", err
}
return string(hashed), nil
}

View File

@@ -0,0 +1,37 @@
// pkg/logger/logger.go
package logger
import (
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
)
var log *zap.Logger
func init() {
config := zap.NewProductionConfig()
config.EncoderConfig.TimeKey = "timestamp"
config.EncoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder
var err error
log, err = config.Build()
if err != nil {
panic(err)
}
}
func Info(msg string, fields ...zap.Field) {
log.Info(msg, fields...)
}
func Error(msg string, fields ...zap.Field) {
log.Error(msg, fields...)
}
func Fatal(msg string, fields ...zap.Field) {
log.Fatal(msg, fields...)
}
func With(fields ...zap.Field) *zap.Logger {
return log.With(fields...)
}

View File

@@ -0,0 +1,67 @@
// pkg/validator/validator.go
package validator
import (
"log"
"regexp"
"strings"
"github.com/go-playground/validator/v10"
)
var (
validate *validator.Validate
emailRegex = regexp.MustCompile(`^[a-z0-9._%+\-]+@[a-z0-9.\-]+\.[a-z]{2,4}$`)
)
func init() {
validate = validator.New()
// Register custom validation
err := validate.RegisterValidation("email", validateEmail)
if err != nil {
log.Print(err)
}
}
func validateEmail(fl validator.FieldLevel) bool {
return emailRegex.MatchString(fl.Field().String())
}
// ValidationError represents a validation error
type ValidationError struct {
Field string `json:"field"`
Message string `json:"message"`
}
// Validate validates a struct and returns validation errors
func Validate(i interface{}) []ValidationError {
var errors []ValidationError
err := validate.Struct(i)
if err != nil {
for _, err := range err.(validator.ValidationErrors) {
var element ValidationError
element.Field = strings.ToLower(err.Field())
element.Message = generateValidationMessage(err)
errors = append(errors, element)
}
}
return errors
}
func generateValidationMessage(err validator.FieldError) string {
switch err.Tag() {
case "required":
return "This field is required"
case "email":
return "Invalid email format"
case "min":
return "Value must be greater than " + err.Param()
case "max":
return "Value must be less than " + err.Param()
default:
return "Invalid value"
}
}

View File

@@ -0,0 +1,41 @@
package scripts
import (
"fmt"
"log"
"go-server/internal/config"
"gorm.io/driver/postgres"
"gorm.io/gorm"
)
// Define models for migration
type User struct {
ID uint `gorm:"primaryKey"`
Email string `gorm:"uniqueIndex"`
Password string
}
// Migrate function to run database migrations
func Migrate() {
// Load from environment variables or configuration
cfg, err := config.LoadConfig()
if err != nil {
panic(err)
}
dsn := cfg.Database.DSN()
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
if err != nil {
log.Fatalf("Failed to connect to database: %v", err)
}
// AutoMigrate runs the migration
err = db.AutoMigrate(&User{})
if err != nil {
log.Fatalf("Migration failed: %v", err)
}
fmt.Println("Migration completed successfully!")
}