Initial commit: Homelab API
This commit is contained in:
5
.env.example
Normal file
5
.env.example
Normal file
@@ -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
|
||||||
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
.env
|
||||||
|
*.log
|
||||||
|
main
|
||||||
|
*.exe
|
||||||
|
.DS_Store
|
||||||
36
Dockerfile
Normal file
36
Dockerfile
Normal file
@@ -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"]
|
||||||
128
cmd/api/main.go
Normal file
128
cmd/api/main.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
47
docker-compose.yml
Normal file
47
docker-compose.yml
Normal file
@@ -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:
|
||||||
12
go.mod
Normal file
12
go.mod
Normal file
@@ -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
|
||||||
|
)
|
||||||
17
go.sum
Normal file
17
go.sum
Normal file
@@ -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=
|
||||||
34
internal/config/config.go
Normal file
34
internal/config/config.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
282
internal/handler/auth.go
Normal file
282
internal/handler/auth.go
Normal file
@@ -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})
|
||||||
|
}
|
||||||
235
internal/handler/habits.go
Normal file
235
internal/handler/habits.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
20
internal/handler/health.go
Normal file
20
internal/handler/health.go
Normal file
@@ -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",
|
||||||
|
})
|
||||||
|
}
|
||||||
186
internal/handler/tasks.go
Normal file
186
internal/handler/tasks.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
81
internal/middleware/auth.go
Normal file
81
internal/middleware/auth.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
32
internal/model/email.go
Normal file
32
internal/model/email.go
Normal file
@@ -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"`
|
||||||
|
}
|
||||||
74
internal/model/habit.go
Normal file
74
internal/model/habit.go
Normal file
@@ -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"`
|
||||||
|
}
|
||||||
48
internal/model/task.go
Normal file
48
internal/model/task.go
Normal file
@@ -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"`
|
||||||
|
}
|
||||||
45
internal/model/user.go
Normal file
45
internal/model/user.go
Normal file
@@ -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"`
|
||||||
|
}
|
||||||
102
internal/repository/db.go
Normal file
102
internal/repository/db.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
113
internal/repository/email_token.go
Normal file
113
internal/repository/email_token.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
271
internal/repository/habit.go
Normal file
271
internal/repository/habit.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
198
internal/repository/task.go
Normal file
198
internal/repository/task.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
94
internal/repository/user.go
Normal file
94
internal/repository/user.go
Normal file
@@ -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")
|
||||||
|
}
|
||||||
292
internal/service/auth.go
Normal file
292
internal/service/auth.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
133
internal/service/email.go
Normal file
133
internal/service/email.go
Normal file
@@ -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(`
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<style>
|
||||||
|
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; background: #FAF9F6; margin: 0; padding: 20px; }
|
||||||
|
.container { max-width: 500px; margin: 0 auto; padding: 40px; background: white; border-radius: 24px; }
|
||||||
|
h2 { color: #0f766e; margin-bottom: 8px; }
|
||||||
|
.button { display: inline-block; padding: 14px 28px; background: linear-gradient(135deg, #14b8a6, #0f766e); color: white; text-decoration: none; border-radius: 16px; font-weight: 600; }
|
||||||
|
.link { color: #0f766e; word-break: break-all; }
|
||||||
|
.footer { margin-top: 30px; font-size: 13px; color: #888; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<h2>Привет, %s! 👋</h2>
|
||||||
|
<p>Спасибо за регистрацию в Pulse! Подтверди свой email, нажав на кнопку ниже:</p>
|
||||||
|
<p style="margin: 30px 0;"><a href="%s" class="button">Подтвердить email</a></p>
|
||||||
|
<p>Или скопируй ссылку:<br><a href="%s" class="link">%s</a></p>
|
||||||
|
<p class="footer">Ссылка действительна 24 часа. Если ты не регистрировался — просто проигнорируй это письмо.</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>`, 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(`
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<style>
|
||||||
|
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; background: #FAF9F6; margin: 0; padding: 20px; }
|
||||||
|
.container { max-width: 500px; margin: 0 auto; padding: 40px; background: white; border-radius: 24px; }
|
||||||
|
h2 { color: #0f766e; margin-bottom: 8px; }
|
||||||
|
.button { display: inline-block; padding: 14px 28px; background: linear-gradient(135deg, #14b8a6, #0f766e); color: white; text-decoration: none; border-radius: 16px; font-weight: 600; }
|
||||||
|
.link { color: #0f766e; word-break: break-all; }
|
||||||
|
.footer { margin-top: 30px; font-size: 13px; color: #888; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<h2>Сброс пароля</h2>
|
||||||
|
<p>Привет, %s! Мы получили запрос на сброс пароля для твоего аккаунта в Pulse.</p>
|
||||||
|
<p style="margin: 30px 0;"><a href="%s" class="button">Сбросить пароль</a></p>
|
||||||
|
<p>Или скопируй ссылку:<br><a href="%s" class="link">%s</a></p>
|
||||||
|
<p class="footer">Ссылка действительна 1 час. Если ты не запрашивал сброс пароля — просто проигнорируй это письмо.</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>`, 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
|
||||||
|
}
|
||||||
191
internal/service/habit.go
Normal file
191
internal/service/habit.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
126
internal/service/task.go
Normal file
126
internal/service/task.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user