From 5a40127edde222df08eab2ec9567ad4527cc7134 Mon Sep 17 00:00:00 2001 From: Cosmo Date: Fri, 6 Feb 2026 11:19:55 +0000 Subject: [PATCH] Initial commit: Homelab API --- .env.example | 5 + .gitignore | 5 + Dockerfile | 36 ++++ cmd/api/main.go | 128 +++++++++++++ docker-compose.yml | 47 +++++ go.mod | 12 ++ go.sum | 17 ++ internal/config/config.go | 34 ++++ internal/handler/auth.go | 282 ++++++++++++++++++++++++++++ internal/handler/habits.go | 235 +++++++++++++++++++++++ internal/handler/health.go | 20 ++ internal/handler/tasks.go | 186 ++++++++++++++++++ internal/middleware/auth.go | 81 ++++++++ internal/model/email.go | 32 ++++ internal/model/habit.go | 74 ++++++++ internal/model/task.go | 48 +++++ internal/model/user.go | 45 +++++ internal/repository/db.go | 102 ++++++++++ internal/repository/email_token.go | 113 +++++++++++ internal/repository/habit.go | 271 ++++++++++++++++++++++++++ internal/repository/task.go | 198 +++++++++++++++++++ internal/repository/user.go | 94 ++++++++++ internal/service/auth.go | 292 +++++++++++++++++++++++++++++ internal/service/email.go | 133 +++++++++++++ internal/service/habit.go | 191 +++++++++++++++++++ internal/service/task.go | 126 +++++++++++++ 26 files changed, 2807 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 cmd/api/main.go create mode 100644 docker-compose.yml create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/config/config.go create mode 100644 internal/handler/auth.go create mode 100644 internal/handler/habits.go create mode 100644 internal/handler/health.go create mode 100644 internal/handler/tasks.go create mode 100644 internal/middleware/auth.go create mode 100644 internal/model/email.go create mode 100644 internal/model/habit.go create mode 100644 internal/model/task.go create mode 100644 internal/model/user.go create mode 100644 internal/repository/db.go create mode 100644 internal/repository/email_token.go create mode 100644 internal/repository/habit.go create mode 100644 internal/repository/task.go create mode 100644 internal/repository/user.go create mode 100644 internal/service/auth.go create mode 100644 internal/service/email.go create mode 100644 internal/service/habit.go create mode 100644 internal/service/task.go diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..d949984 --- /dev/null +++ b/.env.example @@ -0,0 +1,5 @@ +# Database +DB_PASSWORD=change-me-strong-password + +# JWT Secret (generate with: openssl rand -hex 32) +JWT_SECRET=change-me-generate-with-openssl-rand-hex-32 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..555633a --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.env +*.log +main +*.exe +.DS_Store diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..5ebc16d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,36 @@ +# Build stage +FROM golang:1.22-alpine AS builder + +WORKDIR /app + +# Install dependencies +RUN apk add --no-cache git + +# Copy go mod files +COPY go.mod go.sum ./ +RUN go mod download + +# Copy source code +COPY . . + +# Build +RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o main ./cmd/api + +# Final stage +FROM alpine:3.19 + +WORKDIR /app + +# Install ca-certificates for HTTPS +RUN apk --no-cache add ca-certificates tzdata + +# Copy binary from builder +COPY --from=builder /app/main . + +# Create non-root user +RUN adduser -D -g '' appuser +USER appuser + +EXPOSE 8080 + +CMD ["./main"] diff --git a/cmd/api/main.go b/cmd/api/main.go new file mode 100644 index 0000000..07f94d5 --- /dev/null +++ b/cmd/api/main.go @@ -0,0 +1,128 @@ +package main + +import ( + "log" + "net/http" + "os" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" + "github.com/go-chi/cors" + + "github.com/daniil/homelab-api/internal/config" + "github.com/daniil/homelab-api/internal/handler" + customMiddleware "github.com/daniil/homelab-api/internal/middleware" + "github.com/daniil/homelab-api/internal/repository" + "github.com/daniil/homelab-api/internal/service" +) + +func main() { + cfg := config.Load() + + // Initialize database + db, err := repository.NewDB(cfg.DatabaseURL) + if err != nil { + log.Fatalf("Failed to connect to database: %v", err) + } + defer db.Close() + + // Run migrations + if err := repository.RunMigrations(db); err != nil { + log.Fatalf("Failed to run migrations: %v", err) + } + + // Initialize repositories + userRepo := repository.NewUserRepository(db) + habitRepo := repository.NewHabitRepository(db) + taskRepo := repository.NewTaskRepository(db) + emailTokenRepo := repository.NewEmailTokenRepository(db) + + // Initialize services + emailService := service.NewEmailService(cfg.ResendAPIKey, cfg.FromEmail, cfg.FromName, cfg.AppURL) + authService := service.NewAuthService(userRepo, emailTokenRepo, emailService, cfg.JWTSecret) + habitService := service.NewHabitService(habitRepo) + taskService := service.NewTaskService(taskRepo) + + // Initialize handlers + authHandler := handler.NewAuthHandler(authService) + habitHandler := handler.NewHabitHandler(habitService) + taskHandler := handler.NewTaskHandler(taskService) + healthHandler := handler.NewHealthHandler() + + // Initialize middleware + authMiddleware := customMiddleware.NewAuthMiddleware(cfg.JWTSecret) + + // Setup router + r := chi.NewRouter() + + // Global middleware + r.Use(middleware.Logger) + r.Use(middleware.Recoverer) + r.Use(middleware.RequestID) + r.Use(cors.Handler(cors.Options{ + AllowedOrigins: []string{"https://*", "http://*"}, + AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}, + AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-Request-ID"}, + ExposedHeaders: []string{"Link"}, + AllowCredentials: true, + MaxAge: 300, + })) + + // Public routes + r.Get("/health", healthHandler.Health) + + // Auth routes (public) + r.Post("/auth/register", authHandler.Register) + r.Post("/auth/login", authHandler.Login) + r.Post("/auth/refresh", authHandler.Refresh) + r.Post("/auth/verify-email", authHandler.VerifyEmail) + r.Post("/auth/resend-verification", authHandler.ResendVerification) + r.Post("/auth/forgot-password", authHandler.ForgotPassword) + r.Post("/auth/reset-password", authHandler.ResetPassword) + + // Protected routes + r.Group(func(r chi.Router) { + r.Use(authMiddleware.Authenticate) + + // User routes + r.Get("/auth/me", authHandler.Me) + r.Put("/auth/me", authHandler.UpdateProfile) + r.Put("/auth/password", authHandler.ChangePassword) + + // Habits routes + r.Get("/habits", habitHandler.List) + r.Post("/habits", habitHandler.Create) + r.Get("/habits/{id}", habitHandler.Get) + r.Put("/habits/{id}", habitHandler.Update) + r.Delete("/habits/{id}", habitHandler.Delete) + + // Habit logs + r.Post("/habits/{id}/log", habitHandler.Log) + r.Get("/habits/{id}/logs", habitHandler.GetLogs) + r.Delete("/habits/{id}/logs/{logId}", habitHandler.DeleteLog) + + // Stats + r.Get("/habits/stats", habitHandler.Stats) + r.Get("/habits/{id}/stats", habitHandler.HabitStats) + + // Tasks routes + r.Get("/tasks", taskHandler.List) + r.Get("/tasks/today", taskHandler.Today) + r.Post("/tasks", taskHandler.Create) + r.Get("/tasks/{id}", taskHandler.Get) + r.Put("/tasks/{id}", taskHandler.Update) + r.Delete("/tasks/{id}", taskHandler.Delete) + r.Post("/tasks/{id}/complete", taskHandler.Complete) + r.Post("/tasks/{id}/uncomplete", taskHandler.Uncomplete) + }) + + port := os.Getenv("PORT") + if port == "" { + port = "8080" + } + + log.Printf("Server starting on :%s", port) + if err := http.ListenAndServe(":"+port, r); err != nil { + log.Fatalf("Server failed: %v", err) + } +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..9db3ada --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,47 @@ +networks: + proxy: + external: true + name: services_proxy + internal: + driver: bridge + +services: + api: + build: . + container_name: homelab-api + restart: always + environment: + - DATABASE_URL=postgres://homelab:${DB_PASSWORD}@db:5432/homelab?sslmode=disable + - JWT_SECRET=${JWT_SECRET} + - PORT=8080 + - RESEND_API_KEY=${RESEND_API_KEY} + - FROM_EMAIL=${FROM_EMAIL:-noreply@digital-home.site} + - FROM_NAME=${FROM_NAME:-Homelab} + - APP_URL=${APP_URL:-https://api.digital-home.site} + depends_on: + db: + condition: service_healthy + networks: + - proxy + - internal + + db: + image: postgres:16-alpine + container_name: homelab-db + restart: always + environment: + - POSTGRES_USER=homelab + - POSTGRES_PASSWORD=${DB_PASSWORD} + - POSTGRES_DB=homelab + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U homelab"] + interval: 5s + timeout: 5s + retries: 5 + networks: + - internal + +volumes: + postgres_data: diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..f288dae --- /dev/null +++ b/go.mod @@ -0,0 +1,12 @@ +module github.com/daniil/homelab-api + +go 1.22 + +require ( + github.com/go-chi/chi/v5 v5.0.12 + github.com/go-chi/cors v1.2.1 + github.com/golang-jwt/jwt/v5 v5.2.0 + github.com/jmoiron/sqlx v1.3.5 + github.com/lib/pq v1.10.9 + golang.org/x/crypto v0.18.0 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..86f3590 --- /dev/null +++ b/go.sum @@ -0,0 +1,17 @@ +github.com/go-chi/chi/v5 v5.0.12 h1:9euLV5sTrTNTRUU9POmDUvfxyj6LAABLUcEWO+JJb4s= +github.com/go-chi/chi/v5 v5.0.12/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= +github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4= +github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58= +github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE= +github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= +github.com/golang-jwt/jwt/v5 v5.2.0 h1:d/ix8ftRUorsN+5eMIlF4T6J8CAt9rch3My2winC1Jw= +github.com/golang-jwt/jwt/v5 v5.2.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/jmoiron/sqlx v1.3.5 h1:vFFPA71p1o5gAeqtEAwLU4dnX2napprKtHr7PYIcN3g= +github.com/jmoiron/sqlx v1.3.5/go.mod h1:nRVWtLre0KfCLJvgxzCsLVMogSvQ1zNJtpYr2Ccp0mQ= +github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/mattn/go-sqlite3 v1.14.6 h1:dNPt6NO46WmLVt2DLNpwczCmdV5boIZ6g/tlDrlRUbg= +github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= +golang.org/x/crypto v0.18.0 h1:PGVlW0xEltQnzFZ55hkuX5+KLyrMYhHld1YHO4AKcdc= +golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..e1748a2 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,34 @@ +package config + +import ( + "os" +) + +type Config struct { + DatabaseURL string + JWTSecret string + Port string + ResendAPIKey string + FromEmail string + FromName string + AppURL string +} + +func Load() *Config { + return &Config{ + DatabaseURL: getEnv("DATABASE_URL", "postgres://homelab:homelab@db:5432/homelab?sslmode=disable"), + JWTSecret: getEnv("JWT_SECRET", "change-me-in-production"), + Port: getEnv("PORT", "8080"), + ResendAPIKey: getEnv("RESEND_API_KEY", ""), + FromEmail: getEnv("FROM_EMAIL", "noreply@digital-home.site"), + FromName: getEnv("FROM_NAME", "Homelab"), + AppURL: getEnv("APP_URL", "https://api.digital-home.site"), + } +} + +func getEnv(key, defaultValue string) string { + if value := os.Getenv(key); value != "" { + return value + } + return defaultValue +} diff --git a/internal/handler/auth.go b/internal/handler/auth.go new file mode 100644 index 0000000..375137c --- /dev/null +++ b/internal/handler/auth.go @@ -0,0 +1,282 @@ +package handler + +import ( + "encoding/json" + "errors" + "net/http" + + "github.com/daniil/homelab-api/internal/middleware" + "github.com/daniil/homelab-api/internal/model" + "github.com/daniil/homelab-api/internal/repository" + "github.com/daniil/homelab-api/internal/service" +) + +type AuthHandler struct { + authService *service.AuthService +} + +func NewAuthHandler(authService *service.AuthService) *AuthHandler { + return &AuthHandler{authService: authService} +} + +func (h *AuthHandler) Register(w http.ResponseWriter, r *http.Request) { + var req model.RegisterRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, "invalid request body", http.StatusBadRequest) + return + } + + if req.Email == "" || req.Username == "" || req.Password == "" { + writeError(w, "email, username and password are required", http.StatusBadRequest) + return + } + + resp, err := h.authService.Register(&req) + if err != nil { + if errors.Is(err, repository.ErrUserExists) { + writeError(w, "user with this email already exists", http.StatusConflict) + return + } + if errors.Is(err, service.ErrWeakPassword) { + writeError(w, err.Error(), http.StatusBadRequest) + return + } + writeError(w, "internal error", http.StatusInternalServerError) + return + } + + writeJSON(w, resp, http.StatusCreated) +} + +func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) { + var req model.LoginRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, "invalid request body", http.StatusBadRequest) + return + } + + if req.Email == "" || req.Password == "" { + writeError(w, "email and password are required", http.StatusBadRequest) + return + } + + resp, err := h.authService.Login(&req) + if err != nil { + if errors.Is(err, service.ErrInvalidCredentials) { + writeError(w, "invalid email or password", http.StatusUnauthorized) + return + } + if errors.Is(err, service.ErrEmailNotVerified) { + writeError(w, "please verify your email first", http.StatusForbidden) + return + } + writeError(w, "internal error", http.StatusInternalServerError) + return + } + + writeJSON(w, resp, http.StatusOK) +} + +func (h *AuthHandler) Refresh(w http.ResponseWriter, r *http.Request) { + var req model.RefreshRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, "invalid request body", http.StatusBadRequest) + return + } + + if req.RefreshToken == "" { + writeError(w, "refresh_token is required", http.StatusBadRequest) + return + } + + resp, err := h.authService.Refresh(req.RefreshToken) + if err != nil { + writeError(w, "invalid refresh token", http.StatusUnauthorized) + return + } + + writeJSON(w, resp, http.StatusOK) +} + +func (h *AuthHandler) Me(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetUserID(r.Context()) + + user, err := h.authService.GetUser(userID) + if err != nil { + writeError(w, "user not found", http.StatusNotFound) + return + } + + writeJSON(w, user, http.StatusOK) +} + +func (h *AuthHandler) UpdateProfile(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetUserID(r.Context()) + + var req model.UpdateProfileRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, "invalid request body", http.StatusBadRequest) + return + } + + user, err := h.authService.UpdateProfile(userID, &req) + if err != nil { + writeError(w, "failed to update profile", http.StatusInternalServerError) + return + } + + writeJSON(w, user, http.StatusOK) +} + +func (h *AuthHandler) ChangePassword(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetUserID(r.Context()) + + var req model.ChangePasswordRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, "invalid request body", http.StatusBadRequest) + return + } + + if req.OldPassword == "" || req.NewPassword == "" { + writeError(w, "old_password and new_password are required", http.StatusBadRequest) + return + } + + err := h.authService.ChangePassword(userID, &req) + if err != nil { + if errors.Is(err, service.ErrInvalidCredentials) { + writeError(w, "invalid old password", http.StatusUnauthorized) + return + } + if errors.Is(err, service.ErrWeakPassword) { + writeError(w, err.Error(), http.StatusBadRequest) + return + } + writeError(w, "failed to change password", http.StatusInternalServerError) + return + } + + writeJSON(w, map[string]string{"message": "password changed successfully"}, http.StatusOK) +} + +// Email verification + +func (h *AuthHandler) VerifyEmail(w http.ResponseWriter, r *http.Request) { + var req model.VerifyEmailRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, "invalid request body", http.StatusBadRequest) + return + } + + if req.Token == "" { + writeError(w, "token is required", http.StatusBadRequest) + return + } + + err := h.authService.VerifyEmail(&req) + if err != nil { + if errors.Is(err, repository.ErrTokenNotFound) { + writeError(w, "invalid token", http.StatusBadRequest) + return + } + if errors.Is(err, repository.ErrTokenExpired) { + writeError(w, "token expired", http.StatusBadRequest) + return + } + if errors.Is(err, repository.ErrTokenUsed) { + writeError(w, "token already used", http.StatusBadRequest) + return + } + writeError(w, "failed to verify email", http.StatusInternalServerError) + return + } + + writeJSON(w, map[string]string{"message": "email verified successfully"}, http.StatusOK) +} + +func (h *AuthHandler) ResendVerification(w http.ResponseWriter, r *http.Request) { + var req model.ResendVerificationRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, "invalid request body", http.StatusBadRequest) + return + } + + if req.Email == "" { + writeError(w, "email is required", http.StatusBadRequest) + return + } + + // Always return success to not reveal if user exists + _ = h.authService.ResendVerification(&req) + + writeJSON(w, map[string]string{"message": "if the email exists, a verification link has been sent"}, http.StatusOK) +} + +// Password reset + +func (h *AuthHandler) ForgotPassword(w http.ResponseWriter, r *http.Request) { + var req model.ForgotPasswordRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, "invalid request body", http.StatusBadRequest) + return + } + + if req.Email == "" { + writeError(w, "email is required", http.StatusBadRequest) + return + } + + // Always return success to not reveal if user exists + _ = h.authService.ForgotPassword(&req) + + writeJSON(w, map[string]string{"message": "if the email exists, a password reset link has been sent"}, http.StatusOK) +} + +func (h *AuthHandler) ResetPassword(w http.ResponseWriter, r *http.Request) { + var req model.ResetPasswordRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, "invalid request body", http.StatusBadRequest) + return + } + + if req.Token == "" || req.NewPassword == "" { + writeError(w, "token and new_password are required", http.StatusBadRequest) + return + } + + err := h.authService.ResetPassword(&req) + if err != nil { + if errors.Is(err, repository.ErrTokenNotFound) { + writeError(w, "invalid token", http.StatusBadRequest) + return + } + if errors.Is(err, repository.ErrTokenExpired) { + writeError(w, "token expired", http.StatusBadRequest) + return + } + if errors.Is(err, repository.ErrTokenUsed) { + writeError(w, "token already used", http.StatusBadRequest) + return + } + if errors.Is(err, service.ErrWeakPassword) { + writeError(w, err.Error(), http.StatusBadRequest) + return + } + writeError(w, "failed to reset password", http.StatusInternalServerError) + return + } + + writeJSON(w, map[string]string{"message": "password reset successfully"}, http.StatusOK) +} + +func writeJSON(w http.ResponseWriter, data interface{}, status int) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + json.NewEncoder(w).Encode(data) +} + +func writeError(w http.ResponseWriter, message string, status int) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + json.NewEncoder(w).Encode(map[string]string{"error": message}) +} diff --git a/internal/handler/habits.go b/internal/handler/habits.go new file mode 100644 index 0000000..df22054 --- /dev/null +++ b/internal/handler/habits.go @@ -0,0 +1,235 @@ +package handler + +import ( + "encoding/json" + "errors" + "net/http" + "strconv" + + "github.com/go-chi/chi/v5" + + "github.com/daniil/homelab-api/internal/middleware" + "github.com/daniil/homelab-api/internal/model" + "github.com/daniil/homelab-api/internal/repository" + "github.com/daniil/homelab-api/internal/service" +) + +type HabitHandler struct { + habitService *service.HabitService +} + +func NewHabitHandler(habitService *service.HabitService) *HabitHandler { + return &HabitHandler{habitService: habitService} +} + +func (h *HabitHandler) List(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetUserID(r.Context()) + includeArchived := r.URL.Query().Get("archived") == "true" + + habits, err := h.habitService.List(userID, includeArchived) + if err != nil { + writeError(w, "failed to fetch habits", http.StatusInternalServerError) + return + } + + writeJSON(w, habits, http.StatusOK) +} + +func (h *HabitHandler) Create(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetUserID(r.Context()) + + var req model.CreateHabitRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, "invalid request body", http.StatusBadRequest) + return + } + + if req.Name == "" { + writeError(w, "name is required", http.StatusBadRequest) + return + } + + habit, err := h.habitService.Create(userID, &req) + if err != nil { + writeError(w, "failed to create habit", http.StatusInternalServerError) + return + } + + writeJSON(w, habit, http.StatusCreated) +} + +func (h *HabitHandler) Get(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetUserID(r.Context()) + habitID, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) + if err != nil { + writeError(w, "invalid habit id", http.StatusBadRequest) + return + } + + habit, err := h.habitService.Get(habitID, userID) + if err != nil { + if errors.Is(err, repository.ErrHabitNotFound) { + writeError(w, "habit not found", http.StatusNotFound) + return + } + writeError(w, "failed to fetch habit", http.StatusInternalServerError) + return + } + + writeJSON(w, habit, http.StatusOK) +} + +func (h *HabitHandler) Update(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetUserID(r.Context()) + habitID, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) + if err != nil { + writeError(w, "invalid habit id", http.StatusBadRequest) + return + } + + var req model.UpdateHabitRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, "invalid request body", http.StatusBadRequest) + return + } + + habit, err := h.habitService.Update(habitID, userID, &req) + if err != nil { + if errors.Is(err, repository.ErrHabitNotFound) { + writeError(w, "habit not found", http.StatusNotFound) + return + } + writeError(w, "failed to update habit", http.StatusInternalServerError) + return + } + + writeJSON(w, habit, http.StatusOK) +} + +func (h *HabitHandler) Delete(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetUserID(r.Context()) + habitID, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) + if err != nil { + writeError(w, "invalid habit id", http.StatusBadRequest) + return + } + + if err := h.habitService.Delete(habitID, userID); err != nil { + if errors.Is(err, repository.ErrHabitNotFound) { + writeError(w, "habit not found", http.StatusNotFound) + return + } + writeError(w, "failed to delete habit", http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusNoContent) +} + +func (h *HabitHandler) Log(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetUserID(r.Context()) + habitID, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) + if err != nil { + writeError(w, "invalid habit id", http.StatusBadRequest) + return + } + + var req model.LogHabitRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + // Allow empty body - means log today with count=1 + req = model.LogHabitRequest{} + } + + log, err := h.habitService.Log(habitID, userID, &req) + if err != nil { + if errors.Is(err, repository.ErrHabitNotFound) { + writeError(w, "habit not found", http.StatusNotFound) + return + } + writeError(w, "failed to log habit", http.StatusInternalServerError) + return + } + + writeJSON(w, log, http.StatusCreated) +} + +func (h *HabitHandler) GetLogs(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetUserID(r.Context()) + habitID, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) + if err != nil { + writeError(w, "invalid habit id", http.StatusBadRequest) + return + } + + days := 30 + if d := r.URL.Query().Get("days"); d != "" { + if parsed, err := strconv.Atoi(d); err == nil && parsed > 0 { + days = parsed + } + } + + logs, err := h.habitService.GetLogs(habitID, userID, days) + if err != nil { + if errors.Is(err, repository.ErrHabitNotFound) { + writeError(w, "habit not found", http.StatusNotFound) + return + } + writeError(w, "failed to fetch logs", http.StatusInternalServerError) + return + } + + writeJSON(w, logs, http.StatusOK) +} + +func (h *HabitHandler) DeleteLog(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetUserID(r.Context()) + logID, err := strconv.ParseInt(chi.URLParam(r, "logId"), 10, 64) + if err != nil { + writeError(w, "invalid log id", http.StatusBadRequest) + return + } + + if err := h.habitService.DeleteLog(logID, userID); err != nil { + if errors.Is(err, repository.ErrLogNotFound) { + writeError(w, "log not found", http.StatusNotFound) + return + } + writeError(w, "failed to delete log", http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusNoContent) +} + +func (h *HabitHandler) Stats(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetUserID(r.Context()) + + stats, err := h.habitService.GetOverallStats(userID) + if err != nil { + writeError(w, "failed to fetch stats", http.StatusInternalServerError) + return + } + + writeJSON(w, stats, http.StatusOK) +} + +func (h *HabitHandler) HabitStats(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetUserID(r.Context()) + habitID, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) + if err != nil { + writeError(w, "invalid habit id", http.StatusBadRequest) + return + } + + stats, err := h.habitService.GetHabitStats(habitID, userID) + if err != nil { + if errors.Is(err, repository.ErrHabitNotFound) { + writeError(w, "habit not found", http.StatusNotFound) + return + } + writeError(w, "failed to fetch stats", http.StatusInternalServerError) + return + } + + writeJSON(w, stats, http.StatusOK) +} diff --git a/internal/handler/health.go b/internal/handler/health.go new file mode 100644 index 0000000..da963e8 --- /dev/null +++ b/internal/handler/health.go @@ -0,0 +1,20 @@ +package handler + +import ( + "encoding/json" + "net/http" +) + +type HealthHandler struct{} + +func NewHealthHandler() *HealthHandler { + return &HealthHandler{} +} + +func (h *HealthHandler) Health(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{ + "status": "ok", + "service": "homelab-api", + }) +} diff --git a/internal/handler/tasks.go b/internal/handler/tasks.go new file mode 100644 index 0000000..11183b3 --- /dev/null +++ b/internal/handler/tasks.go @@ -0,0 +1,186 @@ +package handler + +import ( + "encoding/json" + "errors" + "net/http" + "strconv" + + "github.com/go-chi/chi/v5" + + "github.com/daniil/homelab-api/internal/middleware" + "github.com/daniil/homelab-api/internal/model" + "github.com/daniil/homelab-api/internal/repository" + "github.com/daniil/homelab-api/internal/service" +) + +type TaskHandler struct { + taskService *service.TaskService +} + +func NewTaskHandler(taskService *service.TaskService) *TaskHandler { + return &TaskHandler{taskService: taskService} +} + +func (h *TaskHandler) List(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetUserID(r.Context()) + + var completed *bool + if c := r.URL.Query().Get("completed"); c != "" { + b := c == "true" + completed = &b + } + + tasks, err := h.taskService.List(userID, completed) + if err != nil { + writeError(w, "failed to fetch tasks", http.StatusInternalServerError) + return + } + + writeJSON(w, tasks, http.StatusOK) +} + +func (h *TaskHandler) Today(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetUserID(r.Context()) + + tasks, err := h.taskService.GetTodayTasks(userID) + if err != nil { + writeError(w, "failed to fetch today tasks", http.StatusInternalServerError) + return + } + + writeJSON(w, tasks, http.StatusOK) +} + +func (h *TaskHandler) Create(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetUserID(r.Context()) + + var req model.CreateTaskRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, "invalid request body", http.StatusBadRequest) + return + } + + if req.Title == "" { + writeError(w, "title is required", http.StatusBadRequest) + return + } + + task, err := h.taskService.Create(userID, &req) + if err != nil { + writeError(w, "failed to create task", http.StatusInternalServerError) + return + } + + writeJSON(w, task, http.StatusCreated) +} + +func (h *TaskHandler) Get(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetUserID(r.Context()) + taskID, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) + if err != nil { + writeError(w, "invalid task id", http.StatusBadRequest) + return + } + + task, err := h.taskService.Get(taskID, userID) + if err != nil { + if errors.Is(err, repository.ErrTaskNotFound) { + writeError(w, "task not found", http.StatusNotFound) + return + } + writeError(w, "failed to fetch task", http.StatusInternalServerError) + return + } + + writeJSON(w, task, http.StatusOK) +} + +func (h *TaskHandler) Update(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetUserID(r.Context()) + taskID, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) + if err != nil { + writeError(w, "invalid task id", http.StatusBadRequest) + return + } + + var req model.UpdateTaskRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, "invalid request body", http.StatusBadRequest) + return + } + + task, err := h.taskService.Update(taskID, userID, &req) + if err != nil { + if errors.Is(err, repository.ErrTaskNotFound) { + writeError(w, "task not found", http.StatusNotFound) + return + } + writeError(w, "failed to update task", http.StatusInternalServerError) + return + } + + writeJSON(w, task, http.StatusOK) +} + +func (h *TaskHandler) Delete(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetUserID(r.Context()) + taskID, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) + if err != nil { + writeError(w, "invalid task id", http.StatusBadRequest) + return + } + + if err := h.taskService.Delete(taskID, userID); err != nil { + if errors.Is(err, repository.ErrTaskNotFound) { + writeError(w, "task not found", http.StatusNotFound) + return + } + writeError(w, "failed to delete task", http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusNoContent) +} + +func (h *TaskHandler) Complete(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetUserID(r.Context()) + taskID, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) + if err != nil { + writeError(w, "invalid task id", http.StatusBadRequest) + return + } + + task, err := h.taskService.Complete(taskID, userID) + if err != nil { + if errors.Is(err, repository.ErrTaskNotFound) { + writeError(w, "task not found", http.StatusNotFound) + return + } + writeError(w, "failed to complete task", http.StatusInternalServerError) + return + } + + writeJSON(w, task, http.StatusOK) +} + +func (h *TaskHandler) Uncomplete(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetUserID(r.Context()) + taskID, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) + if err != nil { + writeError(w, "invalid task id", http.StatusBadRequest) + return + } + + task, err := h.taskService.Uncomplete(taskID, userID) + if err != nil { + if errors.Is(err, repository.ErrTaskNotFound) { + writeError(w, "task not found", http.StatusNotFound) + return + } + writeError(w, "failed to uncomplete task", http.StatusInternalServerError) + return + } + + writeJSON(w, task, http.StatusOK) +} diff --git a/internal/middleware/auth.go b/internal/middleware/auth.go new file mode 100644 index 0000000..a37637a --- /dev/null +++ b/internal/middleware/auth.go @@ -0,0 +1,81 @@ +package middleware + +import ( + "context" + "net/http" + "strings" + + "github.com/golang-jwt/jwt/v5" +) + +type contextKey string + +const UserIDKey contextKey = "user_id" + +type AuthMiddleware struct { + jwtSecret string +} + +func NewAuthMiddleware(jwtSecret string) *AuthMiddleware { + return &AuthMiddleware{jwtSecret: jwtSecret} +} + +func (m *AuthMiddleware) Authenticate(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + authHeader := r.Header.Get("Authorization") + if authHeader == "" { + http.Error(w, `{"error":"missing authorization header"}`, http.StatusUnauthorized) + return + } + + parts := strings.Split(authHeader, " ") + if len(parts) != 2 || parts[0] != "Bearer" { + http.Error(w, `{"error":"invalid authorization header"}`, http.StatusUnauthorized) + return + } + + tokenString := parts[1] + + token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, jwt.ErrSignatureInvalid + } + return []byte(m.jwtSecret), nil + }) + + if err != nil || !token.Valid { + http.Error(w, `{"error":"invalid token"}`, http.StatusUnauthorized) + return + } + + claims, ok := token.Claims.(jwt.MapClaims) + if !ok { + http.Error(w, `{"error":"invalid token claims"}`, http.StatusUnauthorized) + return + } + + // Check token type + tokenType, _ := claims["type"].(string) + if tokenType != "access" { + http.Error(w, `{"error":"invalid token type"}`, http.StatusUnauthorized) + return + } + + userID, ok := claims["user_id"].(float64) + if !ok { + http.Error(w, `{"error":"invalid user id in token"}`, http.StatusUnauthorized) + return + } + + ctx := context.WithValue(r.Context(), UserIDKey, int64(userID)) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +func GetUserID(ctx context.Context) int64 { + userID, ok := ctx.Value(UserIDKey).(int64) + if !ok { + return 0 + } + return userID +} diff --git a/internal/model/email.go b/internal/model/email.go new file mode 100644 index 0000000..bff8e03 --- /dev/null +++ b/internal/model/email.go @@ -0,0 +1,32 @@ +package model + +import ( + "time" +) + +type EmailToken struct { + ID int64 `db:"id" json:"id"` + UserID int64 `db:"user_id" json:"user_id"` + Token string `db:"token" json:"token"` + Type string `db:"type" json:"type"` // verification, reset + ExpiresAt time.Time `db:"expires_at" json:"expires_at"` + UsedAt *time.Time `db:"used_at" json:"used_at"` + CreatedAt time.Time `db:"created_at" json:"created_at"` +} + +type ForgotPasswordRequest struct { + Email string `json:"email"` +} + +type ResetPasswordRequest struct { + Token string `json:"token"` + NewPassword string `json:"new_password"` +} + +type VerifyEmailRequest struct { + Token string `json:"token"` +} + +type ResendVerificationRequest struct { + Email string `json:"email"` +} diff --git a/internal/model/habit.go b/internal/model/habit.go new file mode 100644 index 0000000..4fe6b98 --- /dev/null +++ b/internal/model/habit.go @@ -0,0 +1,74 @@ +package model + +import ( + "time" +) + +type Habit struct { + ID int64 `db:"id" json:"id"` + UserID int64 `db:"user_id" json:"user_id"` + Name string `db:"name" json:"name"` + Description string `db:"description" json:"description"` + Color string `db:"color" json:"color"` + Icon string `db:"icon" json:"icon"` + Frequency string `db:"frequency" json:"frequency"` // daily, weekly, custom + TargetDays []int `db:"-" json:"target_days"` // 0=Sun, 1=Mon, etc. + TargetCount int `db:"target_count" json:"target_count"` + IsArchived bool `db:"is_archived" json:"is_archived"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` +} + +type HabitLog struct { + ID int64 `db:"id" json:"id"` + HabitID int64 `db:"habit_id" json:"habit_id"` + UserID int64 `db:"user_id" json:"user_id"` + Date time.Time `db:"date" json:"date"` + Count int `db:"count" json:"count"` + Note string `db:"note" json:"note"` + CreatedAt time.Time `db:"created_at" json:"created_at"` +} + +type CreateHabitRequest struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + Color string `json:"color,omitempty"` + Icon string `json:"icon,omitempty"` + Frequency string `json:"frequency,omitempty"` + TargetDays []int `json:"target_days,omitempty"` + TargetCount int `json:"target_count,omitempty"` +} + +type UpdateHabitRequest struct { + Name *string `json:"name,omitempty"` + Description *string `json:"description,omitempty"` + Color *string `json:"color,omitempty"` + Icon *string `json:"icon,omitempty"` + Frequency *string `json:"frequency,omitempty"` + TargetDays []int `json:"target_days,omitempty"` + TargetCount *int `json:"target_count,omitempty"` + IsArchived *bool `json:"is_archived,omitempty"` +} + +type LogHabitRequest struct { + Date string `json:"date,omitempty"` // YYYY-MM-DD, defaults to today + Count int `json:"count,omitempty"` + Note string `json:"note,omitempty"` +} + +type HabitStats struct { + HabitID int64 `json:"habit_id"` + TotalLogs int `json:"total_logs"` + CurrentStreak int `json:"current_streak"` + LongestStreak int `json:"longest_streak"` + CompletionPct float64 `json:"completion_pct"` + ThisWeek int `json:"this_week"` + ThisMonth int `json:"this_month"` +} + +type OverallStats struct { + TotalHabits int `json:"total_habits"` + ActiveHabits int `json:"active_habits"` + TodayCompleted int `json:"today_completed"` + WeeklyAvg float64 `json:"weekly_avg"` +} diff --git a/internal/model/task.go b/internal/model/task.go new file mode 100644 index 0000000..07b2a6d --- /dev/null +++ b/internal/model/task.go @@ -0,0 +1,48 @@ +package model + +import ( + "database/sql" + "time" +) + +type Task struct { + ID int64 `db:"id" json:"id"` + UserID int64 `db:"user_id" json:"user_id"` + Title string `db:"title" json:"title"` + Description string `db:"description" json:"description"` + Icon string `db:"icon" json:"icon"` + Color string `db:"color" json:"color"` + DueDate sql.NullTime `db:"due_date" json:"-"` + DueDateStr *string `db:"-" json:"due_date"` + Priority int `db:"priority" json:"priority"` // 0=none, 1=low, 2=medium, 3=high + CompletedAt sql.NullTime `db:"completed_at" json:"-"` + Completed bool `db:"-" json:"completed"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` +} + +func (t *Task) ProcessForJSON() { + if t.DueDate.Valid { + s := t.DueDate.Time.Format("2006-01-02") + t.DueDateStr = &s + } + t.Completed = t.CompletedAt.Valid +} + +type CreateTaskRequest struct { + Title string `json:"title"` + Description string `json:"description,omitempty"` + Icon string `json:"icon,omitempty"` + Color string `json:"color,omitempty"` + DueDate *string `json:"due_date,omitempty"` // YYYY-MM-DD + Priority int `json:"priority,omitempty"` +} + +type UpdateTaskRequest struct { + Title *string `json:"title,omitempty"` + Description *string `json:"description,omitempty"` + Icon *string `json:"icon,omitempty"` + Color *string `json:"color,omitempty"` + DueDate *string `json:"due_date,omitempty"` + Priority *int `json:"priority,omitempty"` +} diff --git a/internal/model/user.go b/internal/model/user.go new file mode 100644 index 0000000..1e5bb35 --- /dev/null +++ b/internal/model/user.go @@ -0,0 +1,45 @@ +package model + +import ( + "time" +) + +type User struct { + ID int64 `db:"id" json:"id"` + Email string `db:"email" json:"email"` + Username string `db:"username" json:"username"` + PasswordHash string `db:"password_hash" json:"-"` + EmailVerified bool `db:"email_verified" json:"email_verified"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` +} + +type RegisterRequest struct { + Email string `json:"email"` + Username string `json:"username"` + Password string `json:"password"` +} + +type LoginRequest struct { + Email string `json:"email"` + Password string `json:"password"` +} + +type UpdateProfileRequest struct { + Username string `json:"username,omitempty"` +} + +type ChangePasswordRequest struct { + OldPassword string `json:"old_password"` + NewPassword string `json:"new_password"` +} + +type AuthResponse struct { + User *User `json:"user"` + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` +} + +type RefreshRequest struct { + RefreshToken string `json:"refresh_token"` +} diff --git a/internal/repository/db.go b/internal/repository/db.go new file mode 100644 index 0000000..d1292d2 --- /dev/null +++ b/internal/repository/db.go @@ -0,0 +1,102 @@ +package repository + +import ( + "github.com/jmoiron/sqlx" + _ "github.com/lib/pq" +) + +func NewDB(databaseURL string) (*sqlx.DB, error) { + db, err := sqlx.Connect("postgres", databaseURL) + if err != nil { + return nil, err + } + + db.SetMaxOpenConns(25) + db.SetMaxIdleConns(5) + + return db, nil +} + +func RunMigrations(db *sqlx.DB) error { + migrations := []string{ + `CREATE TABLE IF NOT EXISTS users ( + id SERIAL PRIMARY KEY, + email VARCHAR(255) UNIQUE NOT NULL, + username VARCHAR(100) NOT NULL, + password_hash VARCHAR(255) NOT NULL, + email_verified BOOLEAN DEFAULT FALSE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + )`, + `CREATE TABLE IF NOT EXISTS habits ( + id SERIAL PRIMARY KEY, + user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, + name VARCHAR(255) NOT NULL, + description TEXT DEFAULT '''', + color VARCHAR(20) DEFAULT '#6366f1', + icon VARCHAR(50) DEFAULT 'check', + frequency VARCHAR(20) DEFAULT 'daily', + target_days INTEGER[] DEFAULT '{1,2,3,4,5,6,0}', + target_count INTEGER DEFAULT 1, + is_archived BOOLEAN DEFAULT FALSE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + )`, + `CREATE TABLE IF NOT EXISTS habit_logs ( + id SERIAL PRIMARY KEY, + habit_id INTEGER REFERENCES habits(id) ON DELETE CASCADE, + user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, + date DATE NOT NULL, + count INTEGER DEFAULT 1, + note TEXT DEFAULT '''', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(habit_id, date) + )`, + `CREATE TABLE IF NOT EXISTS email_tokens ( + id SERIAL PRIMARY KEY, + user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, + token VARCHAR(255) UNIQUE NOT NULL, + type VARCHAR(20) NOT NULL, + expires_at TIMESTAMP NOT NULL, + used_at TIMESTAMP, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + )`, + `CREATE TABLE IF NOT EXISTS tasks ( + id SERIAL PRIMARY KEY, + user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, + title VARCHAR(255) NOT NULL, + description TEXT DEFAULT '''', + icon VARCHAR(10) DEFAULT '📋', + color VARCHAR(7) DEFAULT '#6B7280', + due_date DATE, + priority INTEGER DEFAULT 0, + completed_at TIMESTAMP, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + )`, + `CREATE INDEX IF NOT EXISTS idx_habits_user_id ON habits(user_id)`, + `CREATE INDEX IF NOT EXISTS idx_habit_logs_habit_id ON habit_logs(habit_id)`, + `CREATE INDEX IF NOT EXISTS idx_habit_logs_date ON habit_logs(date)`, + `CREATE INDEX IF NOT EXISTS idx_habit_logs_user_date ON habit_logs(user_id, date)`, + `CREATE INDEX IF NOT EXISTS idx_email_tokens_token ON email_tokens(token)`, + `CREATE INDEX IF NOT EXISTS idx_email_tokens_user_id ON email_tokens(user_id)`, + `CREATE INDEX IF NOT EXISTS idx_tasks_user_id ON tasks(user_id)`, + `CREATE INDEX IF NOT EXISTS idx_tasks_due_date ON tasks(due_date)`, + `CREATE INDEX IF NOT EXISTS idx_tasks_completed ON tasks(user_id, completed_at)`, + // Migration: add email_verified column if not exists + `DO $$ + BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='users' AND column_name='email_verified') THEN + ALTER TABLE users ADD COLUMN email_verified BOOLEAN DEFAULT FALSE; + END IF; + END $$;`, + } + + for _, migration := range migrations { + if _, err := db.Exec(migration); err != nil { + return err + } + } + + return nil +} diff --git a/internal/repository/email_token.go b/internal/repository/email_token.go new file mode 100644 index 0000000..a70e9a2 --- /dev/null +++ b/internal/repository/email_token.go @@ -0,0 +1,113 @@ +package repository + +import ( + "crypto/rand" + "database/sql" + "encoding/hex" + "errors" + "time" + + "github.com/daniil/homelab-api/internal/model" + "github.com/jmoiron/sqlx" +) + +var ErrTokenNotFound = errors.New("token not found") +var ErrTokenExpired = errors.New("token expired") +var ErrTokenUsed = errors.New("token already used") + +type EmailTokenRepository struct { + db *sqlx.DB +} + +func NewEmailTokenRepository(db *sqlx.DB) *EmailTokenRepository { + return &EmailTokenRepository{db: db} +} + +func (r *EmailTokenRepository) Create(userID int64, tokenType string, expiry time.Duration) (*model.EmailToken, error) { + token, err := generateSecureToken(32) + if err != nil { + return nil, err + } + + emailToken := &model.EmailToken{ + UserID: userID, + Token: token, + Type: tokenType, + ExpiresAt: time.Now().Add(expiry), + } + + query := ` + INSERT INTO email_tokens (user_id, token, type, expires_at) + VALUES ($1, $2, $3, $4) + RETURNING id, created_at` + + err = r.db.QueryRow(query, emailToken.UserID, emailToken.Token, emailToken.Type, emailToken.ExpiresAt). + Scan(&emailToken.ID, &emailToken.CreatedAt) + + if err != nil { + return nil, err + } + + return emailToken, nil +} + +func (r *EmailTokenRepository) GetByToken(token string) (*model.EmailToken, error) { + var emailToken model.EmailToken + query := `SELECT id, user_id, token, type, expires_at, used_at, created_at FROM email_tokens WHERE token = $1` + + if err := r.db.Get(&emailToken, query, token); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrTokenNotFound + } + return nil, err + } + + return &emailToken, nil +} + +func (r *EmailTokenRepository) Validate(token, tokenType string) (*model.EmailToken, error) { + emailToken, err := r.GetByToken(token) + if err != nil { + return nil, err + } + + if emailToken.Type != tokenType { + return nil, ErrTokenNotFound + } + + if emailToken.UsedAt != nil { + return nil, ErrTokenUsed + } + + if time.Now().After(emailToken.ExpiresAt) { + return nil, ErrTokenExpired + } + + return emailToken, nil +} + +func (r *EmailTokenRepository) MarkUsed(id int64) error { + query := `UPDATE email_tokens SET used_at = CURRENT_TIMESTAMP WHERE id = $1` + _, err := r.db.Exec(query, id) + return err +} + +func (r *EmailTokenRepository) DeleteExpired() error { + query := `DELETE FROM email_tokens WHERE expires_at < CURRENT_TIMESTAMP OR used_at IS NOT NULL` + _, err := r.db.Exec(query) + return err +} + +func (r *EmailTokenRepository) DeleteByUserAndType(userID int64, tokenType string) error { + query := `DELETE FROM email_tokens WHERE user_id = $1 AND type = $2 AND used_at IS NULL` + _, err := r.db.Exec(query, userID, tokenType) + return err +} + +func generateSecureToken(length int) (string, error) { + bytes := make([]byte, length) + if _, err := rand.Read(bytes); err != nil { + return "", err + } + return hex.EncodeToString(bytes), nil +} diff --git a/internal/repository/habit.go b/internal/repository/habit.go new file mode 100644 index 0000000..6605f7f --- /dev/null +++ b/internal/repository/habit.go @@ -0,0 +1,271 @@ +package repository + +import ( + "database/sql" + "errors" + "time" + + "github.com/daniil/homelab-api/internal/model" + "github.com/jmoiron/sqlx" + "github.com/lib/pq" +) + +var ErrHabitNotFound = errors.New("habit not found") +var ErrLogNotFound = errors.New("log not found") + +type HabitRepository struct { + db *sqlx.DB +} + +func NewHabitRepository(db *sqlx.DB) *HabitRepository { + return &HabitRepository{db: db} +} + +func (r *HabitRepository) Create(habit *model.Habit) error { + query := ` + INSERT INTO habits (user_id, name, description, color, icon, frequency, target_days, target_count) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + RETURNING id, created_at, updated_at` + + targetDays := pq.Array(habit.TargetDays) + if len(habit.TargetDays) == 0 { + targetDays = pq.Array([]int{0, 1, 2, 3, 4, 5, 6}) + } + + return r.db.QueryRow(query, + habit.UserID, + habit.Name, + habit.Description, + habit.Color, + habit.Icon, + habit.Frequency, + targetDays, + habit.TargetCount, + ).Scan(&habit.ID, &habit.CreatedAt, &habit.UpdatedAt) +} + +func (r *HabitRepository) GetByID(id, userID int64) (*model.Habit, error) { + var habit model.Habit + var targetDays pq.Int64Array + + query := ` + SELECT id, user_id, name, description, color, icon, frequency, target_days, target_count, is_archived, created_at, updated_at + FROM habits WHERE id = $1 AND user_id = $2` + + err := r.db.QueryRow(query, id, userID).Scan( + &habit.ID, &habit.UserID, &habit.Name, &habit.Description, + &habit.Color, &habit.Icon, &habit.Frequency, &targetDays, + &habit.TargetCount, &habit.IsArchived, &habit.CreatedAt, &habit.UpdatedAt, + ) + + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrHabitNotFound + } + return nil, err + } + + habit.TargetDays = make([]int, len(targetDays)) + for i, v := range targetDays { + habit.TargetDays[i] = int(v) + } + + return &habit, nil +} + +func (r *HabitRepository) ListByUser(userID int64, includeArchived bool) ([]model.Habit, error) { + query := ` + SELECT id, user_id, name, description, color, icon, frequency, target_days, target_count, is_archived, created_at, updated_at + FROM habits WHERE user_id = $1` + + if !includeArchived { + query += " AND is_archived = FALSE" + } + query += " ORDER BY created_at DESC" + + rows, err := r.db.Query(query, userID) + if err != nil { + return nil, err + } + defer rows.Close() + + var habits []model.Habit + for rows.Next() { + var habit model.Habit + var targetDays pq.Int64Array + + if err := rows.Scan( + &habit.ID, &habit.UserID, &habit.Name, &habit.Description, + &habit.Color, &habit.Icon, &habit.Frequency, &targetDays, + &habit.TargetCount, &habit.IsArchived, &habit.CreatedAt, &habit.UpdatedAt, + ); err != nil { + return nil, err + } + + habit.TargetDays = make([]int, len(targetDays)) + for i, v := range targetDays { + habit.TargetDays[i] = int(v) + } + + habits = append(habits, habit) + } + + return habits, nil +} + +func (r *HabitRepository) Update(habit *model.Habit) error { + query := ` + UPDATE habits + SET name = $2, description = $3, color = $4, icon = $5, frequency = $6, + target_days = $7, target_count = $8, is_archived = $9, updated_at = CURRENT_TIMESTAMP + WHERE id = $1 AND user_id = $10 + RETURNING updated_at` + + return r.db.QueryRow(query, + habit.ID, + habit.Name, + habit.Description, + habit.Color, + habit.Icon, + habit.Frequency, + pq.Array(habit.TargetDays), + habit.TargetCount, + habit.IsArchived, + habit.UserID, + ).Scan(&habit.UpdatedAt) +} + +func (r *HabitRepository) Delete(id, userID int64) error { + query := `DELETE FROM habits WHERE id = $1 AND user_id = $2` + result, err := r.db.Exec(query, id, userID) + if err != nil { + return err + } + + rows, _ := result.RowsAffected() + if rows == 0 { + return ErrHabitNotFound + } + + return nil +} + +// Logs + +func (r *HabitRepository) CreateLog(log *model.HabitLog) error { + query := ` + INSERT INTO habit_logs (habit_id, user_id, date, count, note) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (habit_id, date) + DO UPDATE SET count = habit_logs.count + EXCLUDED.count, note = EXCLUDED.note + RETURNING id, created_at` + + return r.db.QueryRow(query, log.HabitID, log.UserID, log.Date, log.Count, log.Note). + Scan(&log.ID, &log.CreatedAt) +} + +func (r *HabitRepository) GetLogs(habitID, userID int64, from, to time.Time) ([]model.HabitLog, error) { + query := ` + SELECT id, habit_id, user_id, date, count, note, created_at + FROM habit_logs + WHERE habit_id = $1 AND user_id = $2 AND date >= $3 AND date <= $4 + ORDER BY date DESC` + + var logs []model.HabitLog + if err := r.db.Select(&logs, query, habitID, userID, from, to); err != nil { + return nil, err + } + + return logs, nil +} + +func (r *HabitRepository) DeleteLog(logID, userID int64) error { + query := `DELETE FROM habit_logs WHERE id = $1 AND user_id = $2` + result, err := r.db.Exec(query, logID, userID) + if err != nil { + return err + } + + rows, _ := result.RowsAffected() + if rows == 0 { + return ErrLogNotFound + } + + return nil +} + +func (r *HabitRepository) GetUserLogsForDate(userID int64, date time.Time) ([]model.HabitLog, error) { + query := ` + SELECT id, habit_id, user_id, date, count, note, created_at + FROM habit_logs + WHERE user_id = $1 AND date = $2` + + var logs []model.HabitLog + if err := r.db.Select(&logs, query, userID, date); err != nil { + return nil, err + } + + return logs, nil +} + +func (r *HabitRepository) GetStats(habitID, userID int64) (*model.HabitStats, error) { + stats := &model.HabitStats{HabitID: habitID} + + // Total logs + r.db.Get(&stats.TotalLogs, `SELECT COUNT(*) FROM habit_logs WHERE habit_id = $1 AND user_id = $2`, habitID, userID) + + // This week + weekStart := time.Now().AddDate(0, 0, -int(time.Now().Weekday())) + r.db.Get(&stats.ThisWeek, `SELECT COUNT(*) FROM habit_logs WHERE habit_id = $1 AND user_id = $2 AND date >= $3`, habitID, userID, weekStart) + + // This month + monthStart := time.Date(time.Now().Year(), time.Now().Month(), 1, 0, 0, 0, 0, time.UTC) + r.db.Get(&stats.ThisMonth, `SELECT COUNT(*) FROM habit_logs WHERE habit_id = $1 AND user_id = $2 AND date >= $3`, habitID, userID, monthStart) + + // Streaks calculation + stats.CurrentStreak, stats.LongestStreak = r.calculateStreaks(habitID, userID) + + return stats, nil +} + +func (r *HabitRepository) calculateStreaks(habitID, userID int64) (current, longest int) { + query := `SELECT date FROM habit_logs WHERE habit_id = $1 AND user_id = $2 ORDER BY date DESC` + + var dates []time.Time + if err := r.db.Select(&dates, query, habitID, userID); err != nil || len(dates) == 0 { + return 0, 0 + } + + today := time.Now().Truncate(24 * time.Hour) + yesterday := today.AddDate(0, 0, -1) + + // Current streak + if dates[0].Truncate(24*time.Hour).Equal(today) || dates[0].Truncate(24*time.Hour).Equal(yesterday) { + current = 1 + for i := 1; i < len(dates); i++ { + expected := dates[i-1].AddDate(0, 0, -1).Truncate(24 * time.Hour) + if dates[i].Truncate(24 * time.Hour).Equal(expected) { + current++ + } else { + break + } + } + } + + // Longest streak + streak := 1 + longest = 1 + for i := 1; i < len(dates); i++ { + expected := dates[i-1].AddDate(0, 0, -1).Truncate(24 * time.Hour) + if dates[i].Truncate(24 * time.Hour).Equal(expected) { + streak++ + if streak > longest { + longest = streak + } + } else { + streak = 1 + } + } + + return current, longest +} diff --git a/internal/repository/task.go b/internal/repository/task.go new file mode 100644 index 0000000..88d3305 --- /dev/null +++ b/internal/repository/task.go @@ -0,0 +1,198 @@ +package repository + +import ( + "database/sql" + "errors" + "time" + + "github.com/daniil/homelab-api/internal/model" + "github.com/jmoiron/sqlx" +) + +var ErrTaskNotFound = errors.New("task not found") + +type TaskRepository struct { + db *sqlx.DB +} + +func NewTaskRepository(db *sqlx.DB) *TaskRepository { + return &TaskRepository{db: db} +} + +func (r *TaskRepository) Create(task *model.Task) error { + query := ` + INSERT INTO tasks (user_id, title, description, icon, color, due_date, priority) + VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING id, created_at, updated_at` + + return r.db.QueryRow(query, + task.UserID, + task.Title, + task.Description, + task.Icon, + task.Color, + task.DueDate, + task.Priority, + ).Scan(&task.ID, &task.CreatedAt, &task.UpdatedAt) +} + +func (r *TaskRepository) GetByID(id, userID int64) (*model.Task, error) { + var task model.Task + + query := ` + SELECT id, user_id, title, description, icon, color, due_date, priority, completed_at, created_at, updated_at + FROM tasks WHERE id = $1 AND user_id = $2` + + err := r.db.QueryRow(query, id, userID).Scan( + &task.ID, &task.UserID, &task.Title, &task.Description, + &task.Icon, &task.Color, &task.DueDate, &task.Priority, + &task.CompletedAt, &task.CreatedAt, &task.UpdatedAt, + ) + + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrTaskNotFound + } + return nil, err + } + + task.ProcessForJSON() + return &task, nil +} + +func (r *TaskRepository) ListByUser(userID int64, completed *bool) ([]model.Task, error) { + query := ` + SELECT id, user_id, title, description, icon, color, due_date, priority, completed_at, created_at, updated_at + FROM tasks WHERE user_id = $1` + + if completed != nil { + if *completed { + query += " AND completed_at IS NOT NULL" + } else { + query += " AND completed_at IS NULL" + } + } + query += " ORDER BY COALESCE(due_date, '9999-12-31'::date), priority DESC, created_at DESC" + + rows, err := r.db.Query(query, userID) + if err != nil { + return nil, err + } + defer rows.Close() + + var tasks []model.Task + for rows.Next() { + var task model.Task + + if err := rows.Scan( + &task.ID, &task.UserID, &task.Title, &task.Description, + &task.Icon, &task.Color, &task.DueDate, &task.Priority, + &task.CompletedAt, &task.CreatedAt, &task.UpdatedAt, + ); err != nil { + return nil, err + } + + task.ProcessForJSON() + tasks = append(tasks, task) + } + + return tasks, nil +} + +func (r *TaskRepository) GetTodayTasks(userID int64) ([]model.Task, error) { + today := time.Now().Format("2006-01-02") + + query := ` + SELECT id, user_id, title, description, icon, color, due_date, priority, completed_at, created_at, updated_at + FROM tasks + WHERE user_id = $1 AND completed_at IS NULL AND due_date <= $2 + ORDER BY priority DESC, due_date, created_at` + + rows, err := r.db.Query(query, userID, today) + if err != nil { + return nil, err + } + defer rows.Close() + + var tasks []model.Task + for rows.Next() { + var task model.Task + + if err := rows.Scan( + &task.ID, &task.UserID, &task.Title, &task.Description, + &task.Icon, &task.Color, &task.DueDate, &task.Priority, + &task.CompletedAt, &task.CreatedAt, &task.UpdatedAt, + ); err != nil { + return nil, err + } + + task.ProcessForJSON() + tasks = append(tasks, task) + } + + return tasks, nil +} + +func (r *TaskRepository) Update(task *model.Task) error { + query := ` + UPDATE tasks + SET title = $2, description = $3, icon = $4, color = $5, due_date = $6, priority = $7, updated_at = CURRENT_TIMESTAMP + WHERE id = $1 AND user_id = $8 + RETURNING updated_at` + + return r.db.QueryRow(query, + task.ID, + task.Title, + task.Description, + task.Icon, + task.Color, + task.DueDate, + task.Priority, + task.UserID, + ).Scan(&task.UpdatedAt) +} + +func (r *TaskRepository) Delete(id, userID int64) error { + query := `DELETE FROM tasks WHERE id = $1 AND user_id = $2` + result, err := r.db.Exec(query, id, userID) + if err != nil { + return err + } + + rows, _ := result.RowsAffected() + if rows == 0 { + return ErrTaskNotFound + } + + return nil +} + +func (r *TaskRepository) Complete(id, userID int64) error { + query := `UPDATE tasks SET completed_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP WHERE id = $1 AND user_id = $2 AND completed_at IS NULL` + result, err := r.db.Exec(query, id, userID) + if err != nil { + return err + } + + rows, _ := result.RowsAffected() + if rows == 0 { + return ErrTaskNotFound + } + + return nil +} + +func (r *TaskRepository) Uncomplete(id, userID int64) error { + query := `UPDATE tasks SET completed_at = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = $1 AND user_id = $2 AND completed_at IS NOT NULL` + result, err := r.db.Exec(query, id, userID) + if err != nil { + return err + } + + rows, _ := result.RowsAffected() + if rows == 0 { + return ErrTaskNotFound + } + + return nil +} diff --git a/internal/repository/user.go b/internal/repository/user.go new file mode 100644 index 0000000..64fd7a6 --- /dev/null +++ b/internal/repository/user.go @@ -0,0 +1,94 @@ +package repository + +import ( + "database/sql" + "errors" + + "github.com/daniil/homelab-api/internal/model" + "github.com/jmoiron/sqlx" +) + +var ErrUserNotFound = errors.New("user not found") +var ErrUserExists = errors.New("user already exists") + +type UserRepository struct { + db *sqlx.DB +} + +func NewUserRepository(db *sqlx.DB) *UserRepository { + return &UserRepository{db: db} +} + +func (r *UserRepository) Create(user *model.User) error { + query := ` + INSERT INTO users (email, username, password_hash, email_verified) + VALUES ($1, $2, $3, $4) + RETURNING id, created_at, updated_at` + + err := r.db.QueryRow(query, user.Email, user.Username, user.PasswordHash, user.EmailVerified). + Scan(&user.ID, &user.CreatedAt, &user.UpdatedAt) + + if err != nil { + if isUniqueViolation(err) { + return ErrUserExists + } + return err + } + + return nil +} + +func (r *UserRepository) GetByID(id int64) (*model.User, error) { + var user model.User + query := `SELECT id, email, username, password_hash, email_verified, created_at, updated_at FROM users WHERE id = $1` + + if err := r.db.Get(&user, query, id); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrUserNotFound + } + return nil, err + } + + return &user, nil +} + +func (r *UserRepository) GetByEmail(email string) (*model.User, error) { + var user model.User + query := `SELECT id, email, username, password_hash, email_verified, created_at, updated_at FROM users WHERE email = $1` + + if err := r.db.Get(&user, query, email); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrUserNotFound + } + return nil, err + } + + return &user, nil +} + +func (r *UserRepository) Update(user *model.User) error { + query := ` + UPDATE users + SET username = $2, updated_at = CURRENT_TIMESTAMP + WHERE id = $1 + RETURNING updated_at` + + return r.db.QueryRow(query, user.ID, user.Username).Scan(&user.UpdatedAt) +} + +func (r *UserRepository) UpdatePassword(id int64, passwordHash string) error { + query := `UPDATE users SET password_hash = $2, updated_at = CURRENT_TIMESTAMP WHERE id = $1` + _, err := r.db.Exec(query, id, passwordHash) + return err +} + +func (r *UserRepository) SetEmailVerified(id int64) error { + query := `UPDATE users SET email_verified = TRUE, updated_at = CURRENT_TIMESTAMP WHERE id = $1` + _, err := r.db.Exec(query, id) + return err +} + +func isUniqueViolation(err error) bool { + return err != nil && (err.Error() == "pq: duplicate key value violates unique constraint \"users_email_key\"" || + err.Error() == "UNIQUE constraint failed: users.email") +} diff --git a/internal/service/auth.go b/internal/service/auth.go new file mode 100644 index 0000000..c425dda --- /dev/null +++ b/internal/service/auth.go @@ -0,0 +1,292 @@ +package service + +import ( + "errors" + "time" + + "github.com/golang-jwt/jwt/v5" + "golang.org/x/crypto/bcrypt" + + "github.com/daniil/homelab-api/internal/model" + "github.com/daniil/homelab-api/internal/repository" +) + +var ( + ErrInvalidCredentials = errors.New("invalid credentials") + ErrInvalidToken = errors.New("invalid token") + ErrWeakPassword = errors.New("password must be at least 8 characters") + ErrEmailNotVerified = errors.New("email not verified") +) + +type AuthService struct { + userRepo *repository.UserRepository + emailTokenRepo *repository.EmailTokenRepository + emailService *EmailService + jwtSecret string +} + +func NewAuthService( + userRepo *repository.UserRepository, + emailTokenRepo *repository.EmailTokenRepository, + emailService *EmailService, + jwtSecret string, +) *AuthService { + return &AuthService{ + userRepo: userRepo, + emailTokenRepo: emailTokenRepo, + emailService: emailService, + jwtSecret: jwtSecret, + } +} + +func (s *AuthService) Register(req *model.RegisterRequest) (*model.AuthResponse, error) { + if len(req.Password) < 8 { + return nil, ErrWeakPassword + } + + hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) + if err != nil { + return nil, err + } + + user := &model.User{ + Email: req.Email, + Username: req.Username, + PasswordHash: string(hash), + EmailVerified: false, + } + + if err := s.userRepo.Create(user); err != nil { + return nil, err + } + + // Send verification email + if err := s.sendVerificationEmail(user); err != nil { + // Log error but don't fail registration + _ = err + } + + return s.generateAuthResponse(user) +} + +func (s *AuthService) Login(req *model.LoginRequest) (*model.AuthResponse, error) { + user, err := s.userRepo.GetByEmail(req.Email) + if err != nil { + if errors.Is(err, repository.ErrUserNotFound) { + return nil, ErrInvalidCredentials + } + return nil, err + } + + if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password)); err != nil { + return nil, ErrInvalidCredentials + } + + // Optional: require email verification + // if !user.EmailVerified { + // return nil, ErrEmailNotVerified + // } + + return s.generateAuthResponse(user) +} + +func (s *AuthService) Refresh(refreshToken string) (*model.AuthResponse, error) { + claims, err := s.validateToken(refreshToken, "refresh") + if err != nil { + return nil, err + } + + userID, ok := claims["user_id"].(float64) + if !ok { + return nil, ErrInvalidToken + } + + user, err := s.userRepo.GetByID(int64(userID)) + if err != nil { + return nil, err + } + + return s.generateAuthResponse(user) +} + +func (s *AuthService) GetUser(userID int64) (*model.User, error) { + return s.userRepo.GetByID(userID) +} + +func (s *AuthService) UpdateProfile(userID int64, req *model.UpdateProfileRequest) (*model.User, error) { + user, err := s.userRepo.GetByID(userID) + if err != nil { + return nil, err + } + + if req.Username != "" { + user.Username = req.Username + } + + if err := s.userRepo.Update(user); err != nil { + return nil, err + } + + return user, nil +} + +func (s *AuthService) ChangePassword(userID int64, req *model.ChangePasswordRequest) error { + if len(req.NewPassword) < 8 { + return ErrWeakPassword + } + + user, err := s.userRepo.GetByID(userID) + if err != nil { + return err + } + + if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.OldPassword)); err != nil { + return ErrInvalidCredentials + } + + hash, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost) + if err != nil { + return err + } + + return s.userRepo.UpdatePassword(userID, string(hash)) +} + +// Email verification + +func (s *AuthService) sendVerificationEmail(user *model.User) error { + // Delete any existing verification tokens + s.emailTokenRepo.DeleteByUserAndType(user.ID, "verification") + + // Create new token (24 hours) + token, err := s.emailTokenRepo.Create(user.ID, "verification", 24*time.Hour) + if err != nil { + return err + } + + return s.emailService.SendVerificationEmail(user.Email, user.Username, token.Token) +} + +func (s *AuthService) VerifyEmail(req *model.VerifyEmailRequest) error { + token, err := s.emailTokenRepo.Validate(req.Token, "verification") + if err != nil { + return err + } + + if err := s.userRepo.SetEmailVerified(token.UserID); err != nil { + return err + } + + return s.emailTokenRepo.MarkUsed(token.ID) +} + +func (s *AuthService) ResendVerification(req *model.ResendVerificationRequest) error { + user, err := s.userRepo.GetByEmail(req.Email) + if err != nil { + // Don't reveal if user exists + return nil + } + + if user.EmailVerified { + return nil + } + + return s.sendVerificationEmail(user) +} + +// Password reset + +func (s *AuthService) ForgotPassword(req *model.ForgotPasswordRequest) error { + user, err := s.userRepo.GetByEmail(req.Email) + if err != nil { + // Don't reveal if user exists + return nil + } + + // Delete any existing reset tokens + s.emailTokenRepo.DeleteByUserAndType(user.ID, "reset") + + // Create new token (1 hour) + token, err := s.emailTokenRepo.Create(user.ID, "reset", 1*time.Hour) + if err != nil { + return err + } + + return s.emailService.SendPasswordResetEmail(user.Email, user.Username, token.Token) +} + +func (s *AuthService) ResetPassword(req *model.ResetPasswordRequest) error { + if len(req.NewPassword) < 8 { + return ErrWeakPassword + } + + token, err := s.emailTokenRepo.Validate(req.Token, "reset") + if err != nil { + return err + } + + hash, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost) + if err != nil { + return err + } + + if err := s.userRepo.UpdatePassword(token.UserID, string(hash)); err != nil { + return err + } + + return s.emailTokenRepo.MarkUsed(token.ID) +} + +func (s *AuthService) generateAuthResponse(user *model.User) (*model.AuthResponse, error) { + accessToken, err := s.generateToken(user.ID, "access", 15*time.Minute) + if err != nil { + return nil, err + } + + refreshToken, err := s.generateToken(user.ID, "refresh", 30*24*time.Hour) + if err != nil { + return nil, err + } + + return &model.AuthResponse{ + User: user, + AccessToken: accessToken, + RefreshToken: refreshToken, + }, nil +} + +func (s *AuthService) generateToken(userID int64, tokenType string, expiry time.Duration) (string, error) { + claims := jwt.MapClaims{ + "user_id": userID, + "type": tokenType, + "exp": time.Now().Add(expiry).Unix(), + "iat": time.Now().Unix(), + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return token.SignedString([]byte(s.jwtSecret)) +} + +func (s *AuthService) validateToken(tokenString, expectedType string) (jwt.MapClaims, error) { + token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, ErrInvalidToken + } + return []byte(s.jwtSecret), nil + }) + + if err != nil || !token.Valid { + return nil, ErrInvalidToken + } + + claims, ok := token.Claims.(jwt.MapClaims) + if !ok { + return nil, ErrInvalidToken + } + + if tokenType, ok := claims["type"].(string); !ok || tokenType != expectedType { + return nil, ErrInvalidToken + } + + return claims, nil +} diff --git a/internal/service/email.go b/internal/service/email.go new file mode 100644 index 0000000..ae5f643 --- /dev/null +++ b/internal/service/email.go @@ -0,0 +1,133 @@ +package service + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" +) + +type EmailService struct { + apiKey string + fromEmail string + fromName string + baseURL string +} + +type ResendRequest struct { + From string `json:"from"` + To []string `json:"to"` + Subject string `json:"subject"` + HTML string `json:"html"` +} + +func NewEmailService(apiKey, fromEmail, fromName, baseURL string) *EmailService { + return &EmailService{ + apiKey: apiKey, + fromEmail: fromEmail, + fromName: fromName, + baseURL: baseURL, + } +} + +func (s *EmailService) SendVerificationEmail(toEmail, username, token string) error { + verifyURL := fmt.Sprintf("%s/verify-email?token=%s", s.baseURL, token) + + html := fmt.Sprintf(` + + + + + + + +
+

Привет, %s! 👋

+

Спасибо за регистрацию в Pulse! Подтверди свой email, нажав на кнопку ниже:

+

Подтвердить email

+

Или скопируй ссылку:
%s

+ +
+ +`, username, verifyURL, verifyURL, verifyURL) + + return s.send(toEmail, "Подтверди свой email — Pulse", html) +} + +func (s *EmailService) SendPasswordResetEmail(toEmail, username, token string) error { + resetURL := fmt.Sprintf("%s/reset-password?token=%s", s.baseURL, token) + + html := fmt.Sprintf(` + + + + + + + +
+

Сброс пароля

+

Привет, %s! Мы получили запрос на сброс пароля для твоего аккаунта в Pulse.

+

Сбросить пароль

+

Или скопируй ссылку:
%s

+ +
+ +`, username, resetURL, resetURL, resetURL) + + return s.send(toEmail, "Сброс пароля — Pulse", html) +} + +func (s *EmailService) send(to, subject, html string) error { + if s.apiKey == "" { + fmt.Printf("[EMAIL] Would send to %s: %s\n", to, subject) + return nil + } + + payload := ResendRequest{ + From: fmt.Sprintf("%s <%s>", s.fromName, s.fromEmail), + To: []string{to}, + Subject: subject, + HTML: html, + } + + jsonData, err := json.Marshal(payload) + if err != nil { + return err + } + + req, err := http.NewRequest("POST", "https://api.resend.com/emails", bytes.NewBuffer(jsonData)) + if err != nil { + return err + } + + req.Header.Set("Authorization", "Bearer "+s.apiKey) + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + return fmt.Errorf("resend API error: %d", resp.StatusCode) + } + + return nil +} diff --git a/internal/service/habit.go b/internal/service/habit.go new file mode 100644 index 0000000..12fcb58 --- /dev/null +++ b/internal/service/habit.go @@ -0,0 +1,191 @@ +package service + +import ( + "time" + + "github.com/daniil/homelab-api/internal/model" + "github.com/daniil/homelab-api/internal/repository" +) + +type HabitService struct { + habitRepo *repository.HabitRepository +} + +func NewHabitService(habitRepo *repository.HabitRepository) *HabitService { + return &HabitService{habitRepo: habitRepo} +} + +func (s *HabitService) Create(userID int64, req *model.CreateHabitRequest) (*model.Habit, error) { + habit := &model.Habit{ + UserID: userID, + Name: req.Name, + Description: req.Description, + Color: defaultString(req.Color, "#6366f1"), + Icon: defaultString(req.Icon, "check"), + Frequency: defaultString(req.Frequency, "daily"), + TargetDays: req.TargetDays, + TargetCount: defaultInt(req.TargetCount, 1), + } + + if err := s.habitRepo.Create(habit); err != nil { + return nil, err + } + + return habit, nil +} + +func (s *HabitService) Get(id, userID int64) (*model.Habit, error) { + return s.habitRepo.GetByID(id, userID) +} + +func (s *HabitService) List(userID int64, includeArchived bool) ([]model.Habit, error) { + habits, err := s.habitRepo.ListByUser(userID, includeArchived) + if err != nil { + return nil, err + } + if habits == nil { + habits = []model.Habit{} + } + return habits, nil +} + +func (s *HabitService) Update(id, userID int64, req *model.UpdateHabitRequest) (*model.Habit, error) { + habit, err := s.habitRepo.GetByID(id, userID) + if err != nil { + return nil, err + } + + if req.Name != nil { + habit.Name = *req.Name + } + if req.Description != nil { + habit.Description = *req.Description + } + if req.Color != nil { + habit.Color = *req.Color + } + if req.Icon != nil { + habit.Icon = *req.Icon + } + if req.Frequency != nil { + habit.Frequency = *req.Frequency + } + if req.TargetDays != nil { + habit.TargetDays = req.TargetDays + } + if req.TargetCount != nil { + habit.TargetCount = *req.TargetCount + } + if req.IsArchived != nil { + habit.IsArchived = *req.IsArchived + } + + if err := s.habitRepo.Update(habit); err != nil { + return nil, err + } + + return habit, nil +} + +func (s *HabitService) Delete(id, userID int64) error { + return s.habitRepo.Delete(id, userID) +} + +func (s *HabitService) Log(habitID, userID int64, req *model.LogHabitRequest) (*model.HabitLog, error) { + // Verify habit exists and belongs to user + if _, err := s.habitRepo.GetByID(habitID, userID); err != nil { + return nil, err + } + + date := time.Now().Truncate(24 * time.Hour) + if req.Date != "" { + parsed, err := time.Parse("2006-01-02", req.Date) + if err != nil { + return nil, err + } + date = parsed + } + + log := &model.HabitLog{ + HabitID: habitID, + UserID: userID, + Date: date, + Count: defaultInt(req.Count, 1), + Note: req.Note, + } + + if err := s.habitRepo.CreateLog(log); err != nil { + return nil, err + } + + return log, nil +} + +func (s *HabitService) GetLogs(habitID, userID int64, days int) ([]model.HabitLog, error) { + // Verify habit exists and belongs to user + if _, err := s.habitRepo.GetByID(habitID, userID); err != nil { + return nil, err + } + + to := time.Now() + from := to.AddDate(0, 0, -days) + + logs, err := s.habitRepo.GetLogs(habitID, userID, from, to) + if err != nil { + return nil, err + } + if logs == nil { + logs = []model.HabitLog{} + } + return logs, nil +} + +func (s *HabitService) DeleteLog(logID, userID int64) error { + return s.habitRepo.DeleteLog(logID, userID) +} + +func (s *HabitService) GetHabitStats(habitID, userID int64) (*model.HabitStats, error) { + // Verify habit exists and belongs to user + if _, err := s.habitRepo.GetByID(habitID, userID); err != nil { + return nil, err + } + + return s.habitRepo.GetStats(habitID, userID) +} + +func (s *HabitService) GetOverallStats(userID int64) (*model.OverallStats, error) { + habits, err := s.habitRepo.ListByUser(userID, false) + if err != nil { + return nil, err + } + + allHabits, err := s.habitRepo.ListByUser(userID, true) + if err != nil { + return nil, err + } + + todayLogs, err := s.habitRepo.GetUserLogsForDate(userID, time.Now().Truncate(24*time.Hour)) + if err != nil { + return nil, err + } + + return &model.OverallStats{ + TotalHabits: len(allHabits), + ActiveHabits: len(habits), + TodayCompleted: len(todayLogs), + }, nil +} + +func defaultString(val, def string) string { + if val == "" { + return def + } + return val +} + +func defaultInt(val, def int) int { + if val == 0 { + return def + } + return val +} diff --git a/internal/service/task.go b/internal/service/task.go new file mode 100644 index 0000000..4a5c993 --- /dev/null +++ b/internal/service/task.go @@ -0,0 +1,126 @@ +package service + +import ( + "database/sql" + "time" + + "github.com/daniil/homelab-api/internal/model" + "github.com/daniil/homelab-api/internal/repository" +) + +type TaskService struct { + taskRepo *repository.TaskRepository +} + +func NewTaskService(taskRepo *repository.TaskRepository) *TaskService { + return &TaskService{taskRepo: taskRepo} +} + +func (s *TaskService) Create(userID int64, req *model.CreateTaskRequest) (*model.Task, error) { + task := &model.Task{ + UserID: userID, + Title: req.Title, + Description: req.Description, + Icon: defaultString(req.Icon, "📋"), + Color: defaultString(req.Color, "#6B7280"), + Priority: req.Priority, + } + + if req.DueDate != nil && *req.DueDate != "" { + parsed, err := time.Parse("2006-01-02", *req.DueDate) + if err == nil { + task.DueDate = sql.NullTime{Time: parsed, Valid: true} + } + } + + if err := s.taskRepo.Create(task); err != nil { + return nil, err + } + + task.ProcessForJSON() + return task, nil +} + +func (s *TaskService) Get(id, userID int64) (*model.Task, error) { + return s.taskRepo.GetByID(id, userID) +} + +func (s *TaskService) List(userID int64, completed *bool) ([]model.Task, error) { + tasks, err := s.taskRepo.ListByUser(userID, completed) + if err != nil { + return nil, err + } + if tasks == nil { + tasks = []model.Task{} + } + return tasks, nil +} + +func (s *TaskService) GetTodayTasks(userID int64) ([]model.Task, error) { + tasks, err := s.taskRepo.GetTodayTasks(userID) + if err != nil { + return nil, err + } + if tasks == nil { + tasks = []model.Task{} + } + return tasks, nil +} + +func (s *TaskService) Update(id, userID int64, req *model.UpdateTaskRequest) (*model.Task, error) { + task, err := s.taskRepo.GetByID(id, userID) + if err != nil { + return nil, err + } + + if req.Title != nil { + task.Title = *req.Title + } + if req.Description != nil { + task.Description = *req.Description + } + if req.Icon != nil { + task.Icon = *req.Icon + } + if req.Color != nil { + task.Color = *req.Color + } + if req.Priority != nil { + task.Priority = *req.Priority + } + if req.DueDate != nil { + if *req.DueDate == "" { + task.DueDate = sql.NullTime{Valid: false} + } else { + parsed, err := time.Parse("2006-01-02", *req.DueDate) + if err == nil { + task.DueDate = sql.NullTime{Time: parsed, Valid: true} + } + } + } + + if err := s.taskRepo.Update(task); err != nil { + return nil, err + } + + task.ProcessForJSON() + return task, nil +} + +func (s *TaskService) Delete(id, userID int64) error { + return s.taskRepo.Delete(id, userID) +} + +func (s *TaskService) Complete(id, userID int64) (*model.Task, error) { + if err := s.taskRepo.Complete(id, userID); err != nil { + return nil, err + } + return s.taskRepo.GetByID(id, userID) +} + +func (s *TaskService) Uncomplete(id, userID int64) (*model.Task, error) { + if err := s.taskRepo.Uncomplete(id, userID); err != nil { + return nil, err + } + return s.taskRepo.GetByID(id, userID) +}