From 2e31565a8f5a4d345a1b12cb7a6a009acfaef2a0 Mon Sep 17 00:00:00 2001 From: Sosokker Date: Sun, 20 Apr 2025 15:58:52 +0700 Subject: [PATCH] initial code commit --- backend/.air.toml | 49 + backend/.gitignore | 25 + backend/Makefile | 107 + backend/cmd/server/main.go | 248 + backend/config.yaml | 44 + backend/go.mod | 125 + backend/go.sum | 384 ++ backend/internal/api/handlers.go | 880 +++ backend/internal/api/middleware.go | 108 + backend/internal/api/oapi-codegen.cfg.yaml | 17 + backend/internal/api/openapi_types.go | 317 + backend/internal/auth/jwt.go | 11 + backend/internal/auth/oauth.go | 103 + backend/internal/auth/state.go | 72 + backend/internal/cache/cache.go | 55 + backend/internal/config/config.go | 135 + backend/internal/domain/attachment.go | 10 + backend/internal/domain/errors.go | 13 + backend/internal/domain/subtask.go | 16 + backend/internal/domain/tag.go | 17 + backend/internal/domain/todo.go | 45 + backend/internal/domain/user.go | 18 + backend/internal/repository/db.go | 42 + backend/internal/repository/interfaces.go | 98 + .../repository/sqlc/queries/attachments.sql | 17 + .../repository/sqlc/queries/subtasks.sql | 36 + .../internal/repository/sqlc/queries/tags.sql | 30 + .../repository/sqlc/queries/todo_tags.sql | 18 + .../repository/sqlc/queries/todos.sql | 47 + .../repository/sqlc/queries/users.sql | 31 + backend/internal/repository/subtask_repo.go | 154 + backend/internal/repository/tag_repo.go | 163 + backend/internal/repository/todo_repo.go | 329 + backend/internal/repository/user_repo.go | 156 + backend/internal/service/auth_service.go | 258 + backend/internal/service/gcs_storage.go | 94 + backend/internal/service/interfaces.go | 147 + backend/internal/service/local_storage.go | 185 + backend/internal/service/subtask_service.go | 140 + backend/internal/service/tag_service.go | 235 + backend/internal/service/todo_service.go | 355 + backend/internal/service/user_service.go | 83 + backend/internal/service/validation.go | 192 + .../migrations/000001_init_schema.down.sql | 23 + backend/migrations/000001_init_schema.up.sql | 128 + backend/openapi.yaml | 1183 ++++ backend/scripts/generate.sh | 0 backend/scripts/migrate.sh | 0 backend/sqlc.yaml | 40 + frontend/.gitignore | 41 + frontend/README.md | 36 + frontend/app/env.d.ts | 5 + frontend/app/error.tsx | 46 + frontend/app/favicon.ico | Bin 0 -> 25931 bytes frontend/app/globals.css | 168 + frontend/app/layout.tsx | 36 + frontend/app/loading.tsx | 27 + frontend/app/login/page.tsx | 221 + frontend/app/not-found.tsx | 34 + frontend/app/page.tsx | 7 + frontend/app/profile/page.tsx | 181 + frontend/app/signup/page.tsx | 258 + frontend/app/todos/kanban/page.tsx | 139 + frontend/app/todos/layout.tsx | 41 + frontend/app/todos/list/page.tsx | 344 + frontend/app/todos/notifications/page.tsx | 232 + frontend/app/todos/page.tsx | 14 + frontend/app/todos/tags/page.tsx | 298 + frontend/components.json | 21 + frontend/components/icons.tsx | 103 + frontend/components/kanban-column.tsx | 99 + frontend/components/multi-select.tsx | 121 + frontend/components/navbar.tsx | 226 + frontend/components/query-client-provider.tsx | 29 + frontend/components/theme-provider.tsx | 11 + frontend/components/todo-card.tsx | 439 ++ frontend/components/todo-form.tsx | 196 + frontend/components/todo-row.tsx | 230 + frontend/components/ui/accordion.tsx | 66 + frontend/components/ui/alert-dialog.tsx | 157 + frontend/components/ui/alert.tsx | 66 + frontend/components/ui/aspect-ratio.tsx | 11 + frontend/components/ui/avatar.tsx | 53 + frontend/components/ui/badge.tsx | 46 + frontend/components/ui/breadcrumb.tsx | 109 + frontend/components/ui/button.tsx | 59 + frontend/components/ui/calendar.tsx | 75 + frontend/components/ui/card.tsx | 92 + frontend/components/ui/carousel.tsx | 241 + frontend/components/ui/chart.tsx | 353 + frontend/components/ui/checkbox.tsx | 32 + frontend/components/ui/collapsible.tsx | 33 + frontend/components/ui/command.tsx | 177 + frontend/components/ui/context-menu.tsx | 252 + frontend/components/ui/dialog.tsx | 135 + frontend/components/ui/drawer.tsx | 132 + frontend/components/ui/dropdown-menu.tsx | 257 + frontend/components/ui/form.tsx | 167 + frontend/components/ui/hover-card.tsx | 44 + frontend/components/ui/input-otp.tsx | 77 + frontend/components/ui/input.tsx | 21 + frontend/components/ui/label.tsx | 24 + frontend/components/ui/menubar.tsx | 276 + frontend/components/ui/navigation-menu.tsx | 168 + frontend/components/ui/pagination.tsx | 127 + frontend/components/ui/popover.tsx | 48 + frontend/components/ui/progress.tsx | 31 + frontend/components/ui/radio-group.tsx | 45 + frontend/components/ui/resizable.tsx | 56 + frontend/components/ui/scroll-area.tsx | 58 + frontend/components/ui/select.tsx | 185 + frontend/components/ui/separator.tsx | 28 + frontend/components/ui/sheet.tsx | 139 + frontend/components/ui/sidebar.tsx | 726 +++ frontend/components/ui/skeleton.tsx | 13 + frontend/components/ui/slider.tsx | 63 + frontend/components/ui/sonner.tsx | 25 + frontend/components/ui/switch.tsx | 31 + frontend/components/ui/table.tsx | 116 + frontend/components/ui/tabs.tsx | 66 + frontend/components/ui/textarea.tsx | 18 + frontend/components/ui/toggle-group.tsx | 73 + frontend/components/ui/toggle.tsx | 47 + frontend/components/ui/tooltip.tsx | 61 + frontend/components/ui/use-mobile.tsx | 19 + frontend/components/ui/use-toast.ts | 194 + frontend/eslint.config.mjs | 16 + frontend/hooks/use-auth.ts | 61 + frontend/hooks/use-mobile.ts | 19 + frontend/hooks/use-mobile.tsx | 19 + frontend/hooks/use-tags.ts | 71 + frontend/hooks/use-toast.ts | 194 + frontend/hooks/use-todos.ts | 70 + frontend/lib/utils.ts | 6 + frontend/next.config.ts | 7 + frontend/package.json | 80 + frontend/pnpm-lock.yaml | 5761 +++++++++++++++++ frontend/postcss.config.mjs | 5 + frontend/public/file.svg | 1 + frontend/public/globe.svg | 1 + frontend/public/gradient-bg.jpg | Bin 0 -> 166863 bytes frontend/public/next.svg | 1 + frontend/public/placeholder-logo.png | Bin 0 -> 958 bytes frontend/public/placeholder-logo.svg | 1 + frontend/public/placeholder-user.jpg | Bin 0 -> 2615 bytes frontend/public/placeholder.jpg | Bin 0 -> 1596 bytes frontend/public/placeholder.svg | 1 + frontend/public/vercel.svg | 1 + frontend/public/window.svg | 1 + frontend/services/api-auth.ts | 26 + frontend/services/api-client.ts | 84 + frontend/services/api-tags.ts | 24 + frontend/services/api-todos.ts | 41 + frontend/services/api-types.ts | 117 + frontend/store/auth-provider.tsx | 17 + frontend/store/auth-store.ts | 58 + frontend/tsconfig.json | 27 + 157 files changed, 22588 insertions(+) create mode 100644 backend/.air.toml create mode 100644 backend/.gitignore create mode 100644 backend/Makefile create mode 100644 backend/cmd/server/main.go create mode 100644 backend/config.yaml create mode 100644 backend/go.mod create mode 100644 backend/go.sum create mode 100644 backend/internal/api/handlers.go create mode 100644 backend/internal/api/middleware.go create mode 100644 backend/internal/api/oapi-codegen.cfg.yaml create mode 100644 backend/internal/api/openapi_types.go create mode 100644 backend/internal/auth/jwt.go create mode 100644 backend/internal/auth/oauth.go create mode 100644 backend/internal/auth/state.go create mode 100644 backend/internal/cache/cache.go create mode 100644 backend/internal/config/config.go create mode 100644 backend/internal/domain/attachment.go create mode 100644 backend/internal/domain/errors.go create mode 100644 backend/internal/domain/subtask.go create mode 100644 backend/internal/domain/tag.go create mode 100644 backend/internal/domain/todo.go create mode 100644 backend/internal/domain/user.go create mode 100644 backend/internal/repository/db.go create mode 100644 backend/internal/repository/interfaces.go create mode 100644 backend/internal/repository/sqlc/queries/attachments.sql create mode 100644 backend/internal/repository/sqlc/queries/subtasks.sql create mode 100644 backend/internal/repository/sqlc/queries/tags.sql create mode 100644 backend/internal/repository/sqlc/queries/todo_tags.sql create mode 100644 backend/internal/repository/sqlc/queries/todos.sql create mode 100644 backend/internal/repository/sqlc/queries/users.sql create mode 100644 backend/internal/repository/subtask_repo.go create mode 100644 backend/internal/repository/tag_repo.go create mode 100644 backend/internal/repository/todo_repo.go create mode 100644 backend/internal/repository/user_repo.go create mode 100644 backend/internal/service/auth_service.go create mode 100644 backend/internal/service/gcs_storage.go create mode 100644 backend/internal/service/interfaces.go create mode 100644 backend/internal/service/local_storage.go create mode 100644 backend/internal/service/subtask_service.go create mode 100644 backend/internal/service/tag_service.go create mode 100644 backend/internal/service/todo_service.go create mode 100644 backend/internal/service/user_service.go create mode 100644 backend/internal/service/validation.go create mode 100644 backend/migrations/000001_init_schema.down.sql create mode 100644 backend/migrations/000001_init_schema.up.sql create mode 100644 backend/openapi.yaml create mode 100644 backend/scripts/generate.sh create mode 100644 backend/scripts/migrate.sh create mode 100644 backend/sqlc.yaml create mode 100644 frontend/.gitignore create mode 100644 frontend/README.md create mode 100644 frontend/app/env.d.ts create mode 100644 frontend/app/error.tsx create mode 100644 frontend/app/favicon.ico create mode 100644 frontend/app/globals.css create mode 100644 frontend/app/layout.tsx create mode 100644 frontend/app/loading.tsx create mode 100644 frontend/app/login/page.tsx create mode 100644 frontend/app/not-found.tsx create mode 100644 frontend/app/page.tsx create mode 100644 frontend/app/profile/page.tsx create mode 100644 frontend/app/signup/page.tsx create mode 100644 frontend/app/todos/kanban/page.tsx create mode 100644 frontend/app/todos/layout.tsx create mode 100644 frontend/app/todos/list/page.tsx create mode 100644 frontend/app/todos/notifications/page.tsx create mode 100644 frontend/app/todos/page.tsx create mode 100644 frontend/app/todos/tags/page.tsx create mode 100644 frontend/components.json create mode 100644 frontend/components/icons.tsx create mode 100644 frontend/components/kanban-column.tsx create mode 100644 frontend/components/multi-select.tsx create mode 100644 frontend/components/navbar.tsx create mode 100644 frontend/components/query-client-provider.tsx create mode 100644 frontend/components/theme-provider.tsx create mode 100644 frontend/components/todo-card.tsx create mode 100644 frontend/components/todo-form.tsx create mode 100644 frontend/components/todo-row.tsx create mode 100644 frontend/components/ui/accordion.tsx create mode 100644 frontend/components/ui/alert-dialog.tsx create mode 100644 frontend/components/ui/alert.tsx create mode 100644 frontend/components/ui/aspect-ratio.tsx create mode 100644 frontend/components/ui/avatar.tsx create mode 100644 frontend/components/ui/badge.tsx create mode 100644 frontend/components/ui/breadcrumb.tsx create mode 100644 frontend/components/ui/button.tsx create mode 100644 frontend/components/ui/calendar.tsx create mode 100644 frontend/components/ui/card.tsx create mode 100644 frontend/components/ui/carousel.tsx create mode 100644 frontend/components/ui/chart.tsx create mode 100644 frontend/components/ui/checkbox.tsx create mode 100644 frontend/components/ui/collapsible.tsx create mode 100644 frontend/components/ui/command.tsx create mode 100644 frontend/components/ui/context-menu.tsx create mode 100644 frontend/components/ui/dialog.tsx create mode 100644 frontend/components/ui/drawer.tsx create mode 100644 frontend/components/ui/dropdown-menu.tsx create mode 100644 frontend/components/ui/form.tsx create mode 100644 frontend/components/ui/hover-card.tsx create mode 100644 frontend/components/ui/input-otp.tsx create mode 100644 frontend/components/ui/input.tsx create mode 100644 frontend/components/ui/label.tsx create mode 100644 frontend/components/ui/menubar.tsx create mode 100644 frontend/components/ui/navigation-menu.tsx create mode 100644 frontend/components/ui/pagination.tsx create mode 100644 frontend/components/ui/popover.tsx create mode 100644 frontend/components/ui/progress.tsx create mode 100644 frontend/components/ui/radio-group.tsx create mode 100644 frontend/components/ui/resizable.tsx create mode 100644 frontend/components/ui/scroll-area.tsx create mode 100644 frontend/components/ui/select.tsx create mode 100644 frontend/components/ui/separator.tsx create mode 100644 frontend/components/ui/sheet.tsx create mode 100644 frontend/components/ui/sidebar.tsx create mode 100644 frontend/components/ui/skeleton.tsx create mode 100644 frontend/components/ui/slider.tsx create mode 100644 frontend/components/ui/sonner.tsx create mode 100644 frontend/components/ui/switch.tsx create mode 100644 frontend/components/ui/table.tsx create mode 100644 frontend/components/ui/tabs.tsx create mode 100644 frontend/components/ui/textarea.tsx create mode 100644 frontend/components/ui/toggle-group.tsx create mode 100644 frontend/components/ui/toggle.tsx create mode 100644 frontend/components/ui/tooltip.tsx create mode 100644 frontend/components/ui/use-mobile.tsx create mode 100644 frontend/components/ui/use-toast.ts create mode 100644 frontend/eslint.config.mjs create mode 100644 frontend/hooks/use-auth.ts create mode 100644 frontend/hooks/use-mobile.ts create mode 100644 frontend/hooks/use-mobile.tsx create mode 100644 frontend/hooks/use-tags.ts create mode 100644 frontend/hooks/use-toast.ts create mode 100644 frontend/hooks/use-todos.ts create mode 100644 frontend/lib/utils.ts create mode 100644 frontend/next.config.ts create mode 100644 frontend/package.json create mode 100644 frontend/pnpm-lock.yaml create mode 100644 frontend/postcss.config.mjs create mode 100644 frontend/public/file.svg create mode 100644 frontend/public/globe.svg create mode 100644 frontend/public/gradient-bg.jpg create mode 100644 frontend/public/next.svg create mode 100644 frontend/public/placeholder-logo.png create mode 100644 frontend/public/placeholder-logo.svg create mode 100644 frontend/public/placeholder-user.jpg create mode 100644 frontend/public/placeholder.jpg create mode 100644 frontend/public/placeholder.svg create mode 100644 frontend/public/vercel.svg create mode 100644 frontend/public/window.svg create mode 100644 frontend/services/api-auth.ts create mode 100644 frontend/services/api-client.ts create mode 100644 frontend/services/api-tags.ts create mode 100644 frontend/services/api-todos.ts create mode 100644 frontend/services/api-types.ts create mode 100644 frontend/store/auth-provider.tsx create mode 100644 frontend/store/auth-store.ts create mode 100644 frontend/tsconfig.json diff --git a/backend/.air.toml b/backend/.air.toml new file mode 100644 index 0000000..8291625 --- /dev/null +++ b/backend/.air.toml @@ -0,0 +1,49 @@ +# .air.toml +root = "." +# Optional! Store the binary in Air's temporary folder, defaults to ./tmp +tmp_dir = "tmp" + +[build] +# Command to build your Go application. Ensure output path is correct. +cmd = "go build -o ./bin/todolist-server ./cmd/server/main.go" +# The final binary executable Air will run. Matches the output of `cmd`. +bin = "bin/todolist-server" +# Additional arguments/flags to pass to the 'bin' command on execution +full_bin = "./bin/todolist-server -config=." # Pass config path + +# Directories to watch for changes. Changes trigger a rebuild and restart. +include_dir = ["cmd", "internal", "migrations", "pkg"] # Add 'pkg' if you use it +# Files to watch specifically (e.g., config, OpenAPI spec) +include_file = ["openapi.yaml", "config.yaml", "sqlc.yaml"] + +# Directories to exclude from watching. Prevents unnecessary rebuilds. +exclude_dir = ["bin", "vendor", "tmp", "scripts", "docs"] +# Files or patterns to exclude. +exclude_file = [] +# Regex patterns for files/dirs to exclude. +exclude_regex = ["_test.go", "_generated.go", "mocks"] # Exclude tests, generated code, mocks +# Files to exclude just from triggering rebuilds (but still watched for other purposes?) +exclude_unchanged = [] + +# File extensions to watch. +include_ext = ["go", "yaml", "sql", "toml"] # Watch Go, YAML, SQL, and TOML files + +# Log name prefix for Air's output +log_name = "air_todolist.log" +# Follow symlinks when watching? +follow_symlink = true + +# --- Execution --- +# Delay between detecting a file change and triggering the build (milliseconds). +# Useful to debounce rapid saves. +delay = 1000 # 1 second + +# --- Logging --- +# Show log colors. +log_color = true + +# --- Misc --- +# Send interrupt signal instead of kill when stopping the process. +send_interrupt = true +# Kill delay (seconds) after sending interrupt signal before force killing. +kill_delay = 5 # seconds \ No newline at end of file diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000..91c2716 --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,25 @@ +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, build artifacts +*.test +*.out +bin/ +vendor/ +*GENERATED* +*generated* + +# Environment variables +.env* +config.yaml # Often contains secrets, check your policy + +# IDE settings +.vscode/ +.idea/ + +# Local development artifacts +/tmp diff --git a/backend/Makefile b/backend/Makefile new file mode 100644 index 0000000..b4862fb --- /dev/null +++ b/backend/Makefile @@ -0,0 +1,107 @@ +.PHONY: help generate build run test migrate-up migrate-down clean docker-db docker-db-stop dev + +BINARY_NAME=todolist-server +CMD_PATH=./cmd/server +OUTPUT_DIR=./bin +CONFIG_PATH=. + +# Tool Paths (adjust if needed) +OAPI_CODEGEN=oapi-codegen +SQLC=sqlc +MIGRATE=migrate + +OPENAPI=openapi.yaml +PKG=api + +DB_URL?=postgresql://postgres:@localhost:5433/postgres?sslmode=disable +MIGRATIONS_PATH=./backend/migrations + +help: + @echo "Usage: make " + @echo "" + @echo "Targets:" + @echo " help Show this help message." + @echo " generate Generate Go code from OpenAPI spec and SQL queries." + @echo " build Build the Go application binary." + @echo " run Build and run the Go application." + @echo " test Run Go tests." + @echo " migrate-up Apply all up database migrations." + @echo " migrate-down Roll back the last database migration." + @echo " migrate-force Force set migration version (e.g., make migrate-force VERSION=1)." + @echo " clean Remove build artifacts." + @echo " docker-db Start a PostgreSQL container using Docker." + @echo " docker-db-stop Stop and remove the PostgreSQL container." + @echo "" + @echo "Environment Variables:" + @echo " DB_URL Database connection URL (used for migrations)." + @echo " Default: Attempts to get from running 'todolist-db' container." + @echo " Example: export DB_URL='postgres://user:password@localhost:5432/todolist?sslmode=disable'" + +dev: + @echo ">> Starting development server with Air live reload..." + @air -c .air.toml + +generate-types: + oapi-codegen --package $(PKG) --generate types -o internal/api/openapi_types.go $(OPENAPI) + +generate-chi: + oapi-codegen --package $(PKG) --generate chi-server -o internal/api/openapi_generated.go $(OPENAPI) + +generate-models: + oapi-codegen --package models --generate models -o internal/api/models/openapi_models_generated.go $(OPENAPI) + +# generate-strict: +# oapi-codegen --package $(PKG) --generate strict-server -o internal/api/openapi_strict_server_generated.go $(OPENAPI) + +generate: generate-types generate-chi generate-models + @echo ">> Generating SQLC code..." + $(SQLC) generate + @echo ">> Tidying modules..." + go mod tidy + +build: + @echo ">> Building binary..." + go build -o $(OUTPUT_DIR)/$(BINARY_NAME) $(CMD_PATH)/main.go + +run: build + @echo ">> Running application..." + $(OUTPUT_DIR)/$(BINARY_NAME) -config=$(CONFIG_PATH) + +test: + @echo ">> Running tests..." + go test ./... -v -cover + +migrate-up: + @echo ">> Applying migrations..." + @if [ -z "$(DB_URL)" ]; then echo "Error: DB_URL is not set. Run 'make docker-db' or set it manually."; exit 1; fi + $(MIGRATE) -database "$(DB_URL)" -path $(MIGRATIONS_PATH) up + +migrate-down: + @echo ">> Rolling back last migration..." + @if [ -z "$(DB_URL)" ]; then echo "Error: DB_URL is not set. Run 'make docker-db' or set it manually."; exit 1; fi + $(MIGRATE) -database "$(DB_URL)" -path $(MIGRATIONS_PATH) down 1 + +migrate-force: + @echo ">> Forcing migration version $(VERSION)..." + @if [ -z "$(DB_URL)" ]; then echo "Error: DB_URL is not set. Run 'make docker-db' or set it manually."; exit 1; fi + @if [ -z "$(VERSION)" ]; then echo "Error: VERSION is not set. Usage: make migrate-force VERSION="; exit 1; fi + $(MIGRATE) -database "$(DB_URL)" -path $(MIGRATIONS_PATH) force $(VERSION) + +clean: + @echo ">> Cleaning build artifacts..." + rm -rf $(OUTPUT_DIR) + +docker-db: + @echo ">> Starting PostgreSQL container..." + @docker run --name todolist-db -e POSTGRES_USER=user \ + -e POSTGRES_PASSWORD=password \ + -e POSTGRES_DB=todolist \ + -p 5432:5432 -d postgres:15-alpine + @echo ">> Waiting for DB to be ready..." + @sleep 5 + @echo ">> DB_URL=$(DB_URL)" + +docker-db-stop: + @echo ">> Stopping and removing PostgreSQL container..." + @docker stop todolist-db || true + @docker rm todolist-db || true \ No newline at end of file diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go new file mode 100644 index 0000000..b4ccc93 --- /dev/null +++ b/backend/cmd/server/main.go @@ -0,0 +1,248 @@ +package main + +import ( + "context" + "errors" + "flag" + "fmt" + "log/slog" + "net/http" + "os" + "os/signal" + "strings" + "syscall" + "time" + + "github.com/Sosokker/todolist-backend/internal/api" + "github.com/Sosokker/todolist-backend/internal/config" + "github.com/Sosokker/todolist-backend/internal/repository" + "github.com/Sosokker/todolist-backend/internal/service" + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" + "github.com/go-chi/cors" + "github.com/golang-migrate/migrate/v4" + _ "github.com/golang-migrate/migrate/v4/database/postgres" + _ "github.com/golang-migrate/migrate/v4/source/file" +) + +func main() { + configPath := flag.String("config", ".", "Path to the config directory or file") + flag.Parse() + + cfg, err := config.LoadConfig(*configPath) + if err != nil { + fmt.Fprintf(os.Stderr, "Error loading configuration: %v\n", err) + os.Exit(1) + } + + logger := setupLogger(cfg.Log) + slog.SetDefault(logger) + + logger.Info("Starting Todolist Backend Service", "version", "1.2.0") + logger.Debug("Configuration loaded", "config", cfg) + + pool, err := repository.NewConnectionPool(cfg.Database) + if err != nil { + logger.Error("Failed to connect to database", "error", err) + os.Exit(1) + } + defer pool.Close() + + if err := runMigrations(cfg.Database.URL); err != nil { + logger.Error("Database migration failed", "error", err) + os.Exit(1) + } + + repoRegistry := repository.NewRepositoryRegistry(pool) + + var storageService service.FileStorageService + switch cfg.Storage.Type { + case "local": + storageService, err = service.NewLocalStorageService(cfg.Storage.Local, logger) + case "gcs": + storageService, err = service.NewGCStorageService(cfg.Storage.GCS, logger) + default: + err = fmt.Errorf("unsupported storage type: %s", cfg.Storage.Type) + } + if err != nil { + logger.Error("Failed to initialize storage service", "error", err, "type", cfg.Storage.Type) + os.Exit(1) + } + + authService := service.NewAuthService(repoRegistry.UserRepo, cfg) + userService := service.NewUserService(repoRegistry.UserRepo) + tagService := service.NewTagService(repoRegistry.TagRepo) + subtaskService := service.NewSubtaskService(repoRegistry.SubtaskRepo) + todoService := service.NewTodoService(repoRegistry.TodoRepo, tagService, subtaskService, storageService) + + services := &service.ServiceRegistry{ + Auth: authService, + User: userService, + Tag: tagService, + Todo: todoService, + Subtask: subtaskService, + Storage: storageService, + } + + apiHandler := api.NewApiHandler(services, cfg, logger) + + r := chi.NewRouter() + + r.Use(middleware.Logger) + r.Use(middleware.RequestID) + r.Use(middleware.RealIP) + r.Use(NewStructuredLogger(logger)) + r.Use(middleware.Recoverer) + r.Use(cors.Handler(cors.Options{ + AllowedOrigins: []string{"http://localhost:3000", "https://your-frontend-domain.com"}, + AllowedMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"}, + AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"}, + ExposedHeaders: []string{"Link"}, + AllowCredentials: true, + MaxAge: 300, + })) + r.Use(middleware.Timeout(60 * time.Second)) + + r.Route(cfg.Server.BasePath, func(subr chi.Router) { + subr.Post("/auth/signup", apiHandler.SignupUserApi) + subr.Post("/auth/login", apiHandler.LoginUserApi) + subr.Get("/auth/google/login", apiHandler.InitiateGoogleLogin) + subr.Get("/auth/google/callback", apiHandler.HandleGoogleCallback) + + subr.Group(func(prot chi.Router) { + prot.Use(api.AuthMiddleware(authService, cfg)) + api.HandlerFromMux(apiHandler, prot) + }) + }) + + r.Get("/health", func(w http.ResponseWriter, r *http.Request) { + if err := pool.Ping(r.Context()); err != nil { + http.Error(w, "Health check failed: DB ping error", http.StatusServiceUnavailable) + return + } + w.WriteHeader(http.StatusOK) + fmt.Fprintln(w, "OK") + }) + + srv := &http.Server{ + Addr: fmt.Sprintf(":%d", cfg.Server.Port), + Handler: r, + ReadTimeout: cfg.Server.ReadTimeout, + WriteTimeout: cfg.Server.WriteTimeout, + IdleTimeout: cfg.Server.IdleTimeout, + } + + go func() { + logger.Info("Server starting", "address", srv.Addr) + if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + logger.Error("Server failed to start", "error", err) + os.Exit(1) + } + }() + + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + <-quit + logger.Info("Shutting down server...") + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + if err := srv.Shutdown(ctx); err != nil { + logger.Error("Server shutdown failed", "error", err) + os.Exit(1) + } + + logger.Info("Server exited gracefully") +} + +func setupLogger(cfg config.LogConfig) *slog.Logger { + var level slog.Level + switch strings.ToLower(cfg.Level) { + case "debug": + level = slog.LevelDebug + case "info": + level = slog.LevelInfo + case "warn": + level = slog.LevelWarn + case "error": + level = slog.LevelError + default: + level = slog.LevelInfo + } + + opts := &slog.HandlerOptions{ + Level: level, + AddSource: level == slog.LevelDebug, + } + + var handler slog.Handler + if cfg.Format == "json" { + handler = slog.NewJSONHandler(os.Stdout, opts) + } else { + handler = slog.NewTextHandler(os.Stdout, opts) + } + + return slog.New(handler) +} + +func runMigrations(databaseURL string) error { + if databaseURL == "" { + return errors.New("database URL is required for migrations") + } + migrationPath := "file://migrations" + + m, err := migrate.New(migrationPath, databaseURL) + if err != nil { + return fmt.Errorf("failed to create migrate instance: %w", err) + } + + err = m.Up() + if err != nil && !errors.Is(err, migrate.ErrNoChange) { + sourceErr, dbErr := m.Close() + slog.Error("Migration close errors", "source_error", sourceErr, "db_error", dbErr) + return fmt.Errorf("failed to apply migrations: %w", err) + } + + if errors.Is(err, migrate.ErrNoChange) { + slog.Info("No new migrations to apply") + } else { + slog.Info("Database migrations applied successfully") + } + + sourceErr, dbErr := m.Close() + if sourceErr != nil { + slog.Error("Error closing migration source", "error", sourceErr) + } + if dbErr != nil { + slog.Error("Error closing migration database connection", "error", dbErr) + } + + return nil +} + +func NewStructuredLogger(logger *slog.Logger) func(next http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor) + start := time.Now() + reqLogger := logger.With( + slog.String("proto", r.Proto), + slog.String("method", r.Method), + slog.String("path", r.URL.Path), + slog.String("remote_addr", r.RemoteAddr), + slog.String("user_agent", r.UserAgent()), + slog.String("request_id", middleware.GetReqID(r.Context())), + ) + + defer func() { + reqLogger.Info("request completed", + slog.Int("status", ww.Status()), + slog.Duration("latency", time.Since(start)), + ) + }() + + next.ServeHTTP(ww, r) + }) + } +} diff --git a/backend/config.yaml b/backend/config.yaml new file mode 100644 index 0000000..5cfbc46 --- /dev/null +++ b/backend/config.yaml @@ -0,0 +1,44 @@ +server: + port: 8080 + readTimeout: 15s + writeTimeout: 15s + idleTimeout: 60s + basePath: "/api/v1" # Matches OpenAPI server URL + +database: + url: "postgresql://postgres:@localhost:5433/postgres?sslmode=disable" # Use env vars in prod + +jwt: + secret: "your-very-secret-key-change-me" # Use env vars in prod + expiryMinutes: 60 + cookieName: "jwt_token" + cookieDomain: "localhost" # Set appropriately for your domain + cookiePath: "/" + cookieSecure: false # Set true if using HTTPS + cookieHttpOnly: true + cookieSameSite: "Lax" # Lax or Strict + +log: + level: "debug" # debug, info, warn, error + format: "json" # json or text + +oauth: + google: + clientId: "YOUR_GOOGLE_CLIENT_ID" # Use env vars + clientSecret: "YOUR_GOOGLE_CLIENT_SECRET" # Use env vars + redirectUrl: "http://localhost:8080/api/v1/auth/google/callback" # Must match Google Console config + scopes: + - "https://www.googleapis.com/auth/userinfo.profile" + - "https://www.googleapis.com/auth/userinfo.email" + stateSecret: "your-oauth-state-secret-change-me" # For signing state cookie + +cache: + defaultExpiration: 5m + cleanupInterval: 10m + +storage: + local: + path: "/" + # gcs: + # bucketName: "your-gcs-bucket-name" # Env: STORAGE_GCS_BUCKETNAME + # credentialsFile: "/path/to/gcs-credentials.json" # Env: GOOGLE_APPLICATION_CREDENTIALS \ No newline at end of file diff --git a/backend/go.mod b/backend/go.mod new file mode 100644 index 0000000..666586d --- /dev/null +++ b/backend/go.mod @@ -0,0 +1,125 @@ +module github.com/Sosokker/todolist-backend + +go 1.24.2 + +tool ( + cloud.google.com/go/storage + github.com/go-chi/chi/v5 + github.com/go-chi/cors + github.com/golang-jwt/jwt/v5 + github.com/golang-migrate/migrate/v4/database/postgres + github.com/golang-migrate/migrate/v4/source/file + github.com/google/uuid + github.com/jackc/pgx/v5/pgconn + github.com/jackc/pgx/v5/pgxpool + github.com/jackc/pgx/v5/stdlib + github.com/jackc/puddle/v2 + github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen + github.com/oapi-codegen/runtime + github.com/patrickmn/go-cache + github.com/spf13/viper + github.com/swaggo/http-swagger + golang.org/x/crypto/bcrypt + golang.org/x/oauth2 + golang.org/x/oauth2/google + google.golang.org/api/option +) + +require ( + github.com/go-chi/chi/v5 v5.2.1 + github.com/go-chi/cors v1.2.1 + github.com/golang-jwt/jwt/v5 v5.2.2 + github.com/golang-migrate/migrate/v4 v4.18.2 + github.com/google/uuid v1.6.0 + github.com/jackc/pgx/v5 v5.7.4 + github.com/oapi-codegen/runtime v1.1.1 + github.com/patrickmn/go-cache v2.1.0+incompatible + github.com/spf13/viper v1.20.1 + golang.org/x/crypto v0.37.0 + golang.org/x/oauth2 v0.29.0 +) + +require ( + cel.dev/expr v0.19.2 // indirect + cloud.google.com/go v0.118.3 // indirect + cloud.google.com/go/auth v0.16.0 // indirect + cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect + cloud.google.com/go/compute/metadata v0.6.0 // indirect + cloud.google.com/go/iam v1.4.1 // indirect + cloud.google.com/go/monitoring v1.24.0 // indirect + cloud.google.com/go/storage v1.51.0 // indirect + github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.25.0 // indirect + github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.51.0 // indirect + github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.51.0 // indirect + github.com/KyleBanks/depth v1.2.1 // indirect + github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/cncf/xds/go v0.0.0-20250121191232-2f005788dc42 // indirect + github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 // indirect + github.com/envoyproxy/go-control-plane/envoy v1.32.4 // indirect + github.com/envoyproxy/protoc-gen-validate v1.2.1 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/fsnotify/fsnotify v1.8.0 // indirect + github.com/getkin/kin-openapi v0.127.0 // indirect + github.com/go-logr/logr v1.4.2 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-openapi/jsonpointer v0.21.0 // indirect + github.com/go-openapi/jsonreference v0.20.0 // indirect + github.com/go-openapi/spec v0.20.6 // indirect + github.com/go-openapi/swag v0.23.0 // indirect + github.com/go-viper/mapstructure/v2 v2.2.1 // indirect + github.com/google/s2a-go v0.1.9 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect + github.com/googleapis/gax-go/v2 v2.14.1 // indirect + github.com/hashicorp/errwrap v1.1.0 // indirect + github.com/hashicorp/go-multierror v1.1.1 // indirect + github.com/invopop/yaml v0.3.1 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/lib/pq v1.10.9 // indirect + github.com/mailru/easyjson v0.7.7 // indirect + github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect + github.com/oapi-codegen/oapi-codegen/v2 v2.4.1 // indirect + github.com/pelletier/go-toml/v2 v2.2.3 // indirect + github.com/perimeterx/marshmallow v1.1.5 // indirect + github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect + github.com/sagikazarmark/locafero v0.7.0 // indirect + github.com/sourcegraph/conc v0.3.0 // indirect + github.com/speakeasy-api/openapi-overlay v0.9.0 // indirect + github.com/spf13/afero v1.12.0 // indirect + github.com/spf13/cast v1.7.1 // indirect + github.com/spf13/pflag v1.0.6 // indirect + github.com/subosito/gotenv v1.6.0 // indirect + github.com/swaggo/files v0.0.0-20220610200504-28940afbdbfe // indirect + github.com/swaggo/http-swagger v1.3.4 // indirect + github.com/swaggo/swag v1.8.1 // indirect + github.com/vmware-labs/yaml-jsonpath v0.3.2 // indirect + go.opentelemetry.io/auto/sdk v1.1.0 // indirect + go.opentelemetry.io/contrib/detectors/gcp v1.34.0 // indirect + go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 // indirect + go.opentelemetry.io/otel v1.35.0 // indirect + go.opentelemetry.io/otel/metric v1.35.0 // indirect + go.opentelemetry.io/otel/sdk v1.35.0 // indirect + go.opentelemetry.io/otel/sdk/metric v1.35.0 // indirect + go.opentelemetry.io/otel/trace v1.35.0 // indirect + go.uber.org/atomic v1.9.0 // indirect + go.uber.org/multierr v1.9.0 // indirect + golang.org/x/mod v0.21.0 // indirect + golang.org/x/net v0.39.0 // indirect + golang.org/x/sync v0.13.0 // indirect + golang.org/x/sys v0.32.0 // indirect + golang.org/x/text v0.24.0 // indirect + golang.org/x/time v0.11.0 // indirect + golang.org/x/tools v0.24.0 // indirect + google.golang.org/api v0.229.0 // indirect + google.golang.org/genproto v0.0.0-20250303144028-a0af3efb3deb // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20250414145226-207652e42e2e // indirect + google.golang.org/grpc v1.71.1 // indirect + google.golang.org/protobuf v1.36.6 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/backend/go.sum b/backend/go.sum new file mode 100644 index 0000000..0b8b339 --- /dev/null +++ b/backend/go.sum @@ -0,0 +1,384 @@ +cel.dev/expr v0.19.2 h1:V354PbqIXr9IQdwy4SYA4xa0HXaWq1BUPAGzugBY5V4= +cel.dev/expr v0.19.2/go.mod h1:MrpN08Q+lEBs+bGYdLxxHkZoUSsCp0nSKTs0nTymJgw= +cloud.google.com/go v0.116.0 h1:B3fRrSDkLRt5qSHWe40ERJvhvnQwdZiHu0bJOpldweE= +cloud.google.com/go v0.118.3 h1:jsypSnrE/w4mJysioGdMBg4MiW/hHx/sArFpaBWHdME= +cloud.google.com/go v0.118.3/go.mod h1:Lhs3YLnBlwJ4KA6nuObNMZ/fCbOQBPuWKPoE0Wa/9Vc= +cloud.google.com/go/auth v0.16.0 h1:Pd8P1s9WkcrBE2n/PhAwKsdrR35V3Sg2II9B+ndM3CU= +cloud.google.com/go/auth v0.16.0/go.mod h1:1howDHJ5IETh/LwYs3ZxvlkXF48aSqqJUM+5o02dNOI= +cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= +cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= +cloud.google.com/go/compute/metadata v0.6.0 h1:A6hENjEsCDtC1k8byVsgwvVcioamEHvZ4j01OwKxG9I= +cloud.google.com/go/compute/metadata v0.6.0/go.mod h1:FjyFAW1MW0C203CEOMDTu3Dk1FlqW3Rga40jzHL4hfg= +cloud.google.com/go/iam v1.4.1 h1:cFC25Nv+u5BkTR/BT1tXdoF2daiVbZ1RLx2eqfQ9RMM= +cloud.google.com/go/iam v1.4.1/go.mod h1:2vUEJpUG3Q9p2UdsyksaKpDzlwOrnMzS30isdReIcLM= +cloud.google.com/go/monitoring v1.24.0 h1:csSKiCJ+WVRgNkRzzz3BPoGjFhjPY23ZTcaenToJxMM= +cloud.google.com/go/monitoring v1.24.0/go.mod h1:Bd1PRK5bmQBQNnuGwHBfUamAV1ys9049oEPHnn4pcsc= +cloud.google.com/go/storage v1.51.0 h1:ZVZ11zCiD7b3k+cH5lQs/qcNaoSz3U9I0jgwVzqDlCw= +cloud.google.com/go/storage v1.51.0/go.mod h1:YEJfu/Ki3i5oHC/7jyTgsGZwdQ8P9hqMqvpi5kRKGgc= +github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0= +github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.25.0 h1:3c8yed4lgqTt+oTQ+JNMDo+F4xprBf+O/il4ZC0nRLw= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.25.0/go.mod h1:obipzmGjfSjam60XLwGfqUkJsfiheAl+TUjG+4yzyPM= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.51.0 h1:fYE9p3esPxA/C0rQ0AHhP0drtPXDRhaWiwg1DPqO7IU= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.51.0/go.mod h1:BnBReJLvVYx2CS/UHOgVz2BXKXD9wsQPxZug20nZhd0= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.51.0 h1:6/0iUd0xrnX7qt+mLNRwg5c0PGv8wpE8K90ryANQwMI= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.51.0/go.mod h1:otE2jQekW/PqXk1Awf5lmfokJx4uwuqcj1ab5SpGeW0= +github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= +github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= +github.com/agiledragon/gomonkey/v2 v2.3.1 h1:k+UnUY0EMNYUFUAQVETGY9uUTxjMdnUkP0ARyJS1zzs= +github.com/agiledragon/gomonkey/v2 v2.3.1/go.mod h1:ap1AmDzcVOAz1YpeJ3TCzIgstoaWLA6jbbgxfB4w2iY= +github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ= +github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk= +github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= +github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= +github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= +github.com/cncf/xds/go v0.0.0-20250121191232-2f005788dc42 h1:Om6kYQYDUk5wWbT0t0q6pvyM49i9XZAv9dDrkDA7gjk= +github.com/cncf/xds/go v0.0.0-20250121191232-2f005788dc42/go.mod h1:W+zGtBO5Y1IgJhy4+A9GOqVhqLpfZi+vwmdNXUehLA8= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dhui/dktest v0.4.4 h1:+I4s6JRE1yGuqflzwqG+aIaMdgXIorCf5P98JnaAWa8= +github.com/dhui/dktest v0.4.4/go.mod h1:4+22R4lgsdAXrDyaH4Nqx2JEz2hLp49MqQmm9HLCQhM= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/docker v27.2.0+incompatible h1:Rk9nIVdfH3+Vz4cyI/uhbINhEZ/oLmc+CBXmH6fbNk4= +github.com/docker/docker v27.2.0+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= +github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/dprotaso/go-yit v0.0.0-20191028211022-135eb7262960/go.mod h1:9HQzr9D/0PGwMEbC3d5AB7oi67+h4TsQqItC1GVYG58= +github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 h1:PRxIJD8XjimM5aTknUK9w6DHLDox2r2M3DI4i2pnd3w= +github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936/go.mod h1:ttYvX5qlB+mlV1okblJqcSMtR4c52UKxDiX9GRBS8+Q= +github.com/envoyproxy/go-control-plane v0.13.4 h1:zEqyPVyku6IvWCFwux4x9RxkLOMUL+1vC9xUFv5l2/M= +github.com/envoyproxy/go-control-plane/envoy v1.32.4 h1:jb83lalDRZSpPWW2Z7Mck/8kXZ5CQAFYVjQcdVIr83A= +github.com/envoyproxy/go-control-plane/envoy v1.32.4/go.mod h1:Gzjc5k8JcJswLjAx1Zm+wSYE20UrLtt7JZMWiWQXQEw= +github.com/envoyproxy/protoc-gen-validate v1.2.1 h1:DEo3O99U8j4hBFwbJfrz9VtgcDfUKS7KJ7spH3d86P8= +github.com/envoyproxy/protoc-gen-validate v1.2.1/go.mod h1:d/C80l/jxXLdfEIhX1W2TmLfsJ31lvEjwamM4DxlWXU= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= +github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M= +github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/getkin/kin-openapi v0.127.0 h1:Mghqi3Dhryf3F8vR370nN67pAERW+3a95vomb3MAREY= +github.com/getkin/kin-openapi v0.127.0/go.mod h1:OZrfXzUfGrNbsKj+xmFBx6E5c6yH3At/tAKSc2UszXM= +github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8= +github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= +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-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= +github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= +github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= +github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= +github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= +github.com/go-openapi/jsonreference v0.20.0 h1:MYlu0sBgChmCfJxxUKZ8g1cPWFOB37YSZqewK7OKeyA= +github.com/go-openapi/jsonreference v0.20.0/go.mod h1:Ag74Ico3lPc+zR+qjn4XBUmXymS4zJbYVCZmcgkasdo= +github.com/go-openapi/spec v0.20.6 h1:ich1RQ3WDbfoeTqTAb+5EIxNmpKVJZWBNah9RAT0jIQ= +github.com/go-openapi/spec v0.20.6/go.mod h1:2OpW+JddWPrpXSCIX8eOx7lZ5iyuWj3RYR6VaaBKcWA= +github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk= +github.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ= +github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= +github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= +github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= +github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM= +github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= +github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss= +github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= +github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/golang-migrate/migrate/v4 v4.18.2 h1:2VSCMz7x7mjyTXx3m2zPokOY82LTRgxK1yQYKo6wWQ8= +github.com/golang-migrate/migrate/v4 v4.18.2/go.mod h1:2CM6tJvn2kqPXwnXO/d3rAQYiyoIm180VsO8PRX6Rpk= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= +github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/googleapis/enterprise-certificate-proxy v0.3.6 h1:GW/XbdyBFQ8Qe+YAmFU9uHLo7OnF5tL52HFAgMmyrf4= +github.com/googleapis/enterprise-certificate-proxy v0.3.6/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA= +github.com/googleapis/gax-go/v2 v2.14.1 h1:hb0FFeiPaQskmvakKu5EbCbpntQn48jyHuvrkurSS/Q= +github.com/googleapis/gax-go/v2 v2.14.1/go.mod h1:Hb/NubMaVM88SrNkvl8X/o8XWwDJEPqouaLeN2IUxoA= +github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= +github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= +github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= +github.com/invopop/yaml v0.3.1 h1:f0+ZpmhfBSS4MhG+4HYseMdJhoeeopbSKbq5Rpeelso= +github.com/invopop/yaml v0.3.1/go.mod h1:PMOp3nn4/12yEZUFfmOuNHJsZToEEOwoWsT+D81KkeA= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.7.4 h1:9wKznZrhWa2QiHL+NjTSPP6yjl3451BX3imWDnokYlg= +github.com/jackc/pgx/v5 v5.7.4/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= +github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= +github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw= +github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8= +github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= +github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= +github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= +github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= +github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= +github.com/oapi-codegen/oapi-codegen/v2 v2.4.1 h1:ykgG34472DWey7TSjd8vIfNykXgjOgYJZoQbKfEeY/Q= +github.com/oapi-codegen/oapi-codegen/v2 v2.4.1/go.mod h1:N5+lY1tiTDV3V1BeHtOxeWXHoPVeApvsvjJqegfoaz8= +github.com/oapi-codegen/runtime v1.1.1 h1:EXLHh0DXIJnWhdRPN2w4MXAzFyE4CskzhNLUmtpMYro= +github.com/oapi-codegen/runtime v1.1.1/go.mod h1:SK9X900oXmPWilYR5/WKPzt3Kqxn/uS/+lbpREv+eCg= +github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.10.2/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= +github.com/onsi/ginkgo v1.16.4 h1:29JGrr5oVBm5ulCWet69zQkzWipVXIol6ygQUe/EzNc= +github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0= +github.com/onsi/ginkgo/v2 v2.1.3/go.mod h1:vw5CSIxN1JObi/U8gcbwft7ZxR2dgaR70JSE3/PpL4c= +github.com/onsi/gomega v1.7.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= +github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= +github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= +github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY= +github.com/onsi/gomega v1.19.0/go.mod h1:LY+I3pBVzYsTBU1AnDwOSxaYi9WoWiqgwooUqq9yPro= +github.com/onsi/gomega v1.27.6 h1:ENqfyGeS5AX/rlXDd/ETokDz93u0YufY1Pgxuy/PvWE= +github.com/onsi/gomega v1.27.6/go.mod h1:PIQNjfQwkP3aQAH7lf7j87O/5FiNr+ZR8+ipb+qQlhg= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= +github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= +github.com/otiai10/copy v1.7.0 h1:hVoPiN+t+7d2nzzwMiDHPSOogsWAStewq3TwU05+clE= +github.com/otiai10/copy v1.7.0/go.mod h1:rmRl6QPdJj6EiUqXQ/4Nn2lLXoNQjFCQbbNrxgc/t3U= +github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc= +github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ= +github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= +github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= +github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s= +github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo= +github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= +github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= +github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo= +github.com/sagikazarmark/locafero v0.7.0/go.mod h1:2za3Cg5rMaTMoG/2Ulr9AwtFaIppKXTRYnozin4aB5k= +github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0= +github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= +github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= +github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= +github.com/speakeasy-api/openapi-overlay v0.9.0 h1:Wrz6NO02cNlLzx1fB093lBlYxSI54VRhy1aSutx0PQg= +github.com/speakeasy-api/openapi-overlay v0.9.0/go.mod h1:f5FloQrHA7MsxYg9djzMD5h6dxrHjVVByWKh7an8TRc= +github.com/spf13/afero v1.12.0 h1:UcOPyRBYczmFn6yvphxkn9ZEOY65cpwGKb5mL36mrqs= +github.com/spf13/afero v1.12.0/go.mod h1:ZTlWwG4/ahT8W7T0WQ5uYmjI9duaLQGy3Q2OAl4sk/4= +github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y= +github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= +github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= +github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4= +github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4= +github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/swaggo/files v0.0.0-20220610200504-28940afbdbfe h1:K8pHPVoTgxFJt1lXuIzzOX7zZhZFldJQK/CgKx9BFIc= +github.com/swaggo/files v0.0.0-20220610200504-28940afbdbfe/go.mod h1:lKJPbtWzJ9JhsTN1k1gZgleJWY/cqq0psdoMmaThG3w= +github.com/swaggo/http-swagger v1.3.4 h1:q7t/XLx0n15H1Q9/tk3Y9L4n210XzJF5WtnDX64a5ww= +github.com/swaggo/http-swagger v1.3.4/go.mod h1:9dAh0unqMBAlbp1uE2Uc2mQTxNMU/ha4UbucIg1MFkQ= +github.com/swaggo/swag v1.8.1 h1:JuARzFX1Z1njbCGz+ZytBR15TFJwF2Q7fu8puJHhQYI= +github.com/swaggo/swag v1.8.1/go.mod h1:ugemnJsPZm/kRwFUnzBlbHRd0JY9zE1M4F+uy2pAaPQ= +github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU= +github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +github.com/vmware-labs/yaml-jsonpath v0.3.2 h1:/5QKeCBGdsInyDCyVNLbXyilb61MXGi9NP674f9Hobk= +github.com/vmware-labs/yaml-jsonpath v0.3.2/go.mod h1:U6whw1z03QyqgWdgXxvVnQ90zN1BWz5V+51Ewf8k+rQ= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= +go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/contrib/detectors/gcp v1.34.0 h1:JRxssobiPg23otYU5SbWtQC//snGVIM3Tx6QRzlQBao= +go.opentelemetry.io/contrib/detectors/gcp v1.34.0/go.mod h1:cV4BMFcscUR/ckqLkbfQmF0PRsq8w/lMGzdbCSveBHo= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0 h1:x7wzEgXfnzJcHDwStJT+mxOz4etr2EcexjqhBvmoakw= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0/go.mod h1:rg+RlpR5dKwaS95IyyZqj5Wd4E13lk/msnTS0Xl9lJM= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 h1:sbiXRNDSWJOTobXh5HyQKjq6wUC5tNybqjIqDpAY4CU= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0/go.mod h1:69uWxva0WgAA/4bu2Yy70SLDBwZXuQ6PbBpbsa5iZrQ= +go.opentelemetry.io/otel v1.29.0 h1:PdomN/Al4q/lN6iBJEN3AwPvUiHPMlt93c8bqTG5Llw= +go.opentelemetry.io/otel v1.29.0/go.mod h1:N/WtXPs1CNCUEx+Agz5uouwCba+i+bJGFicT8SR4NP8= +go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ= +go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y= +go.opentelemetry.io/otel/metric v1.29.0 h1:vPf/HFWTNkPu1aYeIsc98l4ktOQaL6LeSoeV2g+8YLc= +go.opentelemetry.io/otel/metric v1.29.0/go.mod h1:auu/QWieFVWx+DmQOUMgj0F8LHWdgalxXqvp7BII/W8= +go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M= +go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE= +go.opentelemetry.io/otel/sdk v1.35.0 h1:iPctf8iprVySXSKJffSS79eOjl9pvxV9ZqOWT0QejKY= +go.opentelemetry.io/otel/sdk v1.35.0/go.mod h1:+ga1bZliga3DxJ3CQGg3updiaAJoNECOgJREo9KHGQg= +go.opentelemetry.io/otel/sdk/metric v1.35.0 h1:1RriWBmCKgkeHEhM7a2uMjMUfP7MsOF5JpUCaEqEI9o= +go.opentelemetry.io/otel/sdk/metric v1.35.0/go.mod h1:is6XYCUMpcKi+ZsOvfluY5YstFnhW0BidkR+gL+qN+w= +go.opentelemetry.io/otel/trace v1.29.0 h1:J/8ZNK4XgR7a21DZUAsbF8pZ5Jcw1VhACmnYt39JTi4= +go.opentelemetry.io/otel/trace v1.29.0/go.mod h1:eHl3w0sp3paPkYstJOmAimxhiFXPg+MMTlEh3nsQgWQ= +go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs= +go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc= +go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= +go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI= +go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= +golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.21.0 h1:vvrHzRwRfVKSiLrG+d4FMl/Qi4ukBCE6kZlTUkDYRT0= +golang.org/x/mod v0.21.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk= +golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= +golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I= +golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= +golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY= +golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= +golang.org/x/oauth2 v0.29.0 h1:WdYw2tdTK1S8olAzWHdgeqfy+Mtm9XNhv/xJsY65d98= +golang.org/x/oauth2 v0.29.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610= +golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= +golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= +golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= +golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0= +golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.24.0 h1:J1shsA93PJUEVaUSaay7UXAyE8aimq3GW0pjlolpa24= +golang.org/x/tools v0.24.0/go.mod h1:YhNqVBIfWHdzvTLs0d8LCuMhkKUgSUKldakyV7W/WDQ= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/api v0.229.0 h1:p98ymMtqeJ5i3lIBMj5MpR9kzIIgzpHHh8vQ+vgAzx8= +google.golang.org/api v0.229.0/go.mod h1:wyDfmq5g1wYJWn29O22FDWN48P7Xcz0xz+LBpptYvB0= +google.golang.org/genproto v0.0.0-20241118233622-e639e219e697 h1:ToEetK57OidYuqD4Q5w+vfEnPvPpuTwedCNVohYJfNk= +google.golang.org/genproto v0.0.0-20250303144028-a0af3efb3deb h1:ITgPrl429bc6+2ZraNSzMDk3I95nmQln2fuPstKwFDE= +google.golang.org/genproto v0.0.0-20250303144028-a0af3efb3deb/go.mod h1:sAo5UzpjUwgFBCzupwhcLcxHVDK7vG5IqI30YnwX2eE= +google.golang.org/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb h1:p31xT4yrYrSM/G4Sn2+TNUkVhFCbG9y8itM2S6Th950= +google.golang.org/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb/go.mod h1:jbe3Bkdp+Dh2IrslsFCklNhweNTBgSYanP1UXhJDhKg= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250414145226-207652e42e2e h1:ztQaXfzEXTmCBvbtWYRhJxW+0iJcz2qXfd38/e9l7bA= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250414145226-207652e42e2e/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= +google.golang.org/grpc v1.71.1 h1:ffsFWr7ygTUscGPI0KKK6TLrGz0476KUvvsbqWK0rPI= +google.golang.org/grpc v1.71.1/go.mod h1:H0GRtasmQOh9LkFoCPDu3ZrwUtD1YGE+b2vYBYd/8Ec= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= +google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20191026110619-0b21df46bc1d/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/backend/internal/api/handlers.go b/backend/internal/api/handlers.go new file mode 100644 index 0000000..901be5a --- /dev/null +++ b/backend/internal/api/handlers.go @@ -0,0 +1,880 @@ +package api + +import ( + "encoding/json" + "errors" + "fmt" + "log/slog" + "net/http" + "net/url" + "strings" + "time" + + "github.com/Sosokker/todolist-backend/internal/api/models" // Generated models + "github.com/Sosokker/todolist-backend/internal/auth" + "github.com/Sosokker/todolist-backend/internal/config" + "github.com/Sosokker/todolist-backend/internal/domain" + "github.com/Sosokker/todolist-backend/internal/service" + "github.com/google/uuid" + openapi_types "github.com/oapi-codegen/runtime/types" + "golang.org/x/oauth2" +) + +// Compile-time check to ensure ApiHandler implements the interface +var _ ServerInterface = (*ApiHandler)(nil) + +// ApiHandler holds dependencies for API handlers +type ApiHandler struct { + services *service.ServiceRegistry + cfg *config.Config + logger *slog.Logger + // Add other dependencies like cache if needed directly by handlers +} + +// NewApiHandler creates a new handler instance +func NewApiHandler(services *service.ServiceRegistry, cfg *config.Config, logger *slog.Logger) *ApiHandler { + return &ApiHandler{ + services: services, + cfg: cfg, + logger: logger, + } +} + +// --- Helper Functions --- + +// SendJSONResponse sends a JSON response with a status code +func SendJSONResponse(w http.ResponseWriter, statusCode int, payload interface{}, logger *slog.Logger) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(statusCode) + if payload != nil { + err := json.NewEncoder(w).Encode(payload) + if err != nil { // Log the error but response header is already sent + logger.Error("Failed to encode JSON response", "error", err) + } + } +} + +// SendJSONError sends a standardized JSON error response +func SendJSONError(w http.ResponseWriter, err error, defaultStatusCode int, logger *slog.Logger) { + logger.Warn("API Error", "error", err) + + respErr := models.Error{ + Message: err.Error(), + } + statusCode := defaultStatusCode + + // Map domain errors to HTTP status codes + switch { + case errors.Is(err, domain.ErrNotFound): + statusCode = http.StatusNotFound + case errors.Is(err, domain.ErrForbidden): + statusCode = http.StatusForbidden + case errors.Is(err, domain.ErrUnauthorized): + statusCode = http.StatusUnauthorized + case errors.Is(err, domain.ErrConflict): + statusCode = http.StatusConflict + case errors.Is(err, domain.ErrValidation), errors.Is(err, domain.ErrBadRequest): + statusCode = http.StatusBadRequest + case errors.Is(err, domain.ErrInternalServer): + statusCode = http.StatusInternalServerError + respErr.Message = "An internal error occurred." + default: + if statusCode < 500 { + logger.Error("Unhandled error type in API mapping", "error", err, "defaultStatus", defaultStatusCode) + statusCode = http.StatusInternalServerError + respErr.Message = "An unexpected error occurred." + } + } + + respErr.Code = int32(statusCode) + SendJSONResponse(w, statusCode, respErr, logger) +} + +// parseAndValidateBody decodes JSON body and logs/sends error on failure. +func parseAndValidateBody(w http.ResponseWriter, r *http.Request, body interface{}, logger *slog.Logger) bool { + if err := json.NewDecoder(r.Body).Decode(body); err != nil { + SendJSONError(w, fmt.Errorf("invalid request body: %w", domain.ErrBadRequest), http.StatusBadRequest, logger) + return false + } + // TODO: Add struct validation here if needed (e.g., using go-Code Playground/validator) + return true +} + +// --- Mappers (Domain <-> Generated API Models) --- + +func mapDomainUserToApi(user *domain.User) *models.User { + if user == nil { + return nil + } + userID := openapi_types.UUID(user.ID) + email := openapi_types.Email(user.Email) + emailVerified := user.EmailVerified + createdAt := user.CreatedAt + updatedAt := user.UpdatedAt + + return &models.User{ + Id: &userID, + Username: user.Username, + Email: email, + EmailVerified: &emailVerified, + CreatedAt: &createdAt, + UpdatedAt: &updatedAt} +} + +func mapDomainTagToApi(tag *domain.Tag) *models.Tag { + if tag == nil { + return nil + } + tagID := openapi_types.UUID(tag.ID) + userID := openapi_types.UUID(tag.UserID) + createdAt := tag.CreatedAt + updatedAt := tag.UpdatedAt + return &models.Tag{ + Id: &tagID, + UserId: &userID, + Name: tag.Name, + Color: tag.Color, + Icon: tag.Icon, + CreatedAt: &createdAt, + UpdatedAt: &updatedAt} +} + +func mapDomainTodoToApi(todo *domain.Todo) *models.Todo { + if todo == nil { + return nil + } + apiSubtasks := make([]models.Subtask, len(todo.Subtasks)) + for i, st := range todo.Subtasks { + mappedSubtask := mapDomainSubtaskToApi(&st) + if mappedSubtask != nil { + apiSubtasks[i] = *mappedSubtask + } + } + + tagIDs := make([]openapi_types.UUID, len(todo.TagIDs)) + for i, domainID := range todo.TagIDs { + tagIDs[i] = openapi_types.UUID(domainID) + } + + todoID := openapi_types.UUID(todo.ID) + userID := openapi_types.UUID(todo.UserID) + createdAt := todo.CreatedAt + updatedAt := todo.UpdatedAt + + return &models.Todo{ + Id: &todoID, + UserId: &userID, + Title: todo.Title, + Description: todo.Description, + Status: models.TodoStatus(todo.Status), + Deadline: todo.Deadline, + TagIds: tagIDs, + Attachments: todo.Attachments, + Subtasks: &apiSubtasks, + CreatedAt: &createdAt, + UpdatedAt: &updatedAt} +} + +func mapDomainSubtaskToApi(subtask *domain.Subtask) *models.Subtask { + if subtask == nil { + return nil + } + subtaskID := openapi_types.UUID(subtask.ID) + todoID := openapi_types.UUID(subtask.TodoID) + createdAt := subtask.CreatedAt + updatedAt := subtask.UpdatedAt + + return &models.Subtask{ + Id: &subtaskID, + TodoId: &todoID, + Description: subtask.Description, + Completed: subtask.Completed, + CreatedAt: &createdAt, + UpdatedAt: &updatedAt} +} + +func mapDomainAttachmentInfoToApi(info *domain.AttachmentInfo) *models.FileUploadResponse { + if info == nil { + return nil + } + return &models.FileUploadResponse{ + FileId: info.FileID, + FileName: info.FileName, + FileUrl: info.FileURL, + ContentType: info.ContentType, + Size: info.Size, + } +} + +// --- Auth Handlers --- + +func (h *ApiHandler) SignupUserApi(w http.ResponseWriter, r *http.Request) { + var body models.SignupRequest + if !parseAndValidateBody(w, r, &body, h.logger) { + return + } + + creds := service.SignupCredentials{ + Username: body.Username, + Email: string(body.Email), + Password: *body.Password, + } + + user, err := h.services.Auth.Signup(r.Context(), creds) + if err != nil { + SendJSONError(w, err, http.StatusInternalServerError, h.logger) + return + } + + SendJSONResponse(w, http.StatusCreated, mapDomainUserToApi(user), h.logger) +} + +func (h *ApiHandler) LoginUserApi(w http.ResponseWriter, r *http.Request) { + var body models.LoginRequest + if !parseAndValidateBody(w, r, &body, h.logger) { + return + } + + creds := service.LoginCredentials{ + Email: string(body.Email), + Password: *body.Password, + } + + token, _, err := h.services.Auth.Login(r.Context(), creds) + if err != nil { + SendJSONError(w, err, http.StatusUnauthorized, h.logger) + return + } + + http.SetCookie(w, &http.Cookie{ + Name: h.cfg.JWT.CookieName, + Value: token, + Path: h.cfg.JWT.CookiePath, + Domain: h.cfg.JWT.CookieDomain, + Expires: time.Now().Add(time.Duration(h.cfg.JWT.ExpiryMinutes) * time.Minute), + HttpOnly: h.cfg.JWT.CookieHttpOnly, + Secure: h.cfg.JWT.CookieSecure, + SameSite: parseSameSite(h.cfg.JWT.CookieSameSite), + }) + + resp := models.LoginResponse{ + AccessToken: token, + TokenType: "Bearer", + } + SendJSONResponse(w, http.StatusOK, resp, h.logger) +} + +func (h *ApiHandler) LogoutUser(w http.ResponseWriter, r *http.Request) { + http.SetCookie(w, &http.Cookie{ + Name: h.cfg.JWT.CookieName, + Value: "", + Path: h.cfg.JWT.CookiePath, + Domain: h.cfg.JWT.CookieDomain, + Expires: time.Unix(0, 0), + MaxAge: -1, + HttpOnly: h.cfg.JWT.CookieHttpOnly, + Secure: h.cfg.JWT.CookieSecure, + SameSite: parseSameSite(h.cfg.JWT.CookieSameSite), + }) + + w.WriteHeader(http.StatusNoContent) +} + +// Helper to parse SameSite string to http.SameSite type +func parseSameSite(s string) http.SameSite { + switch strings.ToLower(s) { + case "lax": + return http.SameSiteLaxMode + case "strict": + return http.SameSiteStrictMode + case "none": + return http.SameSiteNoneMode + default: + return http.SameSiteDefaultMode + } +} + +// --- Google OAuth Handlers --- + +func (h *ApiHandler) InitiateGoogleLogin(w http.ResponseWriter, r *http.Request) { + oauthCfg := h.services.Auth.GetGoogleAuthConfig() + state := uuid.NewString() + + signedState := auth.SignState(state, []byte(h.cfg.OAuth.Google.StateSecret)) + + http.SetCookie(w, &http.Cookie{ + Name: auth.StateCookieName, + Value: signedState, + Path: "/", + Expires: time.Now().Add(auth.StateExpiry + 1*time.Minute), + HttpOnly: true, + Secure: h.cfg.JWT.CookieSecure, + SameSite: http.SameSiteLaxMode, + }) + + redirectURL := oauthCfg.AuthCodeURL(state, oauth2.AccessTypeOffline) + h.logger.Debug("Redirecting to Google OAuth", "url", redirectURL) + http.Redirect(w, r, redirectURL, http.StatusTemporaryRedirect) +} + +func (h *ApiHandler) HandleGoogleCallback(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + receivedCode := r.URL.Query().Get("code") + receivedState := r.URL.Query().Get("state") + + stateCookie, err := r.Cookie(auth.StateCookieName) + if err != nil { + h.logger.WarnContext(ctx, "OAuth state cookie missing or error", "error", err) + http.Redirect(w, r, "/login?error=state_missing", http.StatusTemporaryRedirect) + return + } + + http.SetCookie(w, &http.Cookie{ + Name: auth.StateCookieName, + Value: "", + Path: "/", + Expires: time.Unix(0, 0), + MaxAge: -1, + HttpOnly: true, + Secure: h.cfg.JWT.CookieSecure, + SameSite: http.SameSiteLaxMode, + }) + + originalState, err := auth.VerifyAndExtractState(stateCookie.Value, []byte(h.cfg.OAuth.Google.StateSecret)) + if err != nil { + h.logger.WarnContext(ctx, "OAuth state verification failed", "error", err, "receivedState", receivedState) + errorParam := "state_invalid" + if errors.Is(err, auth.ErrStateExpired) { + errorParam = "state_expired" + } + http.Redirect(w, r, "/login?error="+errorParam, http.StatusTemporaryRedirect) + return + } + + if receivedState == "" || receivedState != originalState { + h.logger.WarnContext(ctx, "OAuth state mismatch", "received", receivedState, "expected", originalState) + http.Redirect(w, r, "/login?error=state_mismatch", http.StatusTemporaryRedirect) + return + } + + if receivedCode == "" { + errorDesc := r.URL.Query().Get("error_description") + h.logger.WarnContext(ctx, "Missing OAuth code parameter in callback", "error_desc", errorDesc) + errorParam := url.QueryEscape(r.URL.Query().Get("error")) + if errorParam == "" { + errorParam = "missing_code" + } + http.Redirect(w, r, "/login?error="+errorParam, http.StatusTemporaryRedirect) + return + } + + token, user, err := h.services.Auth.HandleGoogleCallback(ctx, receivedCode) + if err != nil { + h.logger.ErrorContext(ctx, "Google callback handling failed in service", "error", err) + errorParam := "auth_failed" + if errors.Is(err, domain.ErrConflict) { + errorParam = "auth_conflict" + } + http.Redirect(w, r, "/login?error="+errorParam, http.StatusTemporaryRedirect) + return + } + + http.SetCookie(w, &http.Cookie{ + Name: h.cfg.JWT.CookieName, + Value: token, + Path: h.cfg.JWT.CookiePath, + Domain: h.cfg.JWT.CookieDomain, + Expires: time.Now().Add(time.Duration(h.cfg.JWT.ExpiryMinutes) * time.Minute), + HttpOnly: h.cfg.JWT.CookieHttpOnly, + Secure: h.cfg.JWT.CookieSecure, + SameSite: parseSameSite(h.cfg.JWT.CookieSameSite), + }) + + redirectURL := "/dashboard" + h.logger.InfoContext(ctx, "Google OAuth login successful", "userId", user.ID, "email", user.Email, "redirectingTo", redirectURL) + http.Redirect(w, r, redirectURL, http.StatusTemporaryRedirect) +} + +// --- User Handlers --- + +func (h *ApiHandler) GetCurrentUser(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + logger := h.logger.With(slog.String("handler", "GetCurrentUser")) + userID, err := GetUserIDFromContext(ctx) + if err != nil { + SendJSONError(w, err, http.StatusInternalServerError, logger) + return + } + logger = logger.With(slog.String("userId", userID.String())) + + user, err := h.services.User.GetUserByID(ctx, userID) + if err != nil { + logger.ErrorContext(ctx, "Failed to fetch user from service", "error", err) + SendJSONError(w, err, http.StatusInternalServerError, logger) + return + } + + apiUser := mapDomainUserToApi(user) + if apiUser == nil { + logger.ErrorContext(ctx, "Failed to map domain user to API model") + SendJSONError(w, domain.ErrInternalServer, http.StatusInternalServerError, logger) + return + } + + logger.DebugContext(ctx, "Successfully retrieved current user") + SendJSONResponse(w, http.StatusOK, apiUser, logger) +} + +func (h *ApiHandler) UpdateCurrentUser(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + logger := h.logger.With(slog.String("handler", "UpdateCurrentUser")) + + userID, err := GetUserIDFromContext(ctx) + if err != nil { + SendJSONError(w, err, http.StatusInternalServerError, logger) + return + } + logger = logger.With(slog.String("userId", userID.String())) + + var body models.UpdateUserRequest + if !parseAndValidateBody(w, r, &body, logger) { + return + } + + updateInput := service.UpdateUserInput{ + Username: body.Username, + } + + updatedUser, err := h.services.User.UpdateUser(ctx, userID, updateInput) + if err != nil { + logger.ErrorContext(ctx, "Failed to update user in service", "error", err) + SendJSONError(w, err, http.StatusInternalServerError, logger) + return + } + + apiUser := mapDomainUserToApi(updatedUser) + if apiUser == nil { + logger.ErrorContext(ctx, "Failed to map updated domain user to API model") + SendJSONError(w, domain.ErrInternalServer, http.StatusInternalServerError, logger) + return + } + + logger.InfoContext(ctx, "Successfully updated current user") + SendJSONResponse(w, http.StatusOK, apiUser, logger) +} + +// --- Tag Handlers --- + +func (h *ApiHandler) CreateTag(w http.ResponseWriter, r *http.Request) { + userID, err := GetUserIDFromContext(r.Context()) + if err != nil { + SendJSONError(w, err, http.StatusInternalServerError, h.logger) + return + } + + var body models.CreateTagRequest + if !parseAndValidateBody(w, r, &body, h.logger) { + // TODO: Add specific field validation checks here or rely on service layer + return + } + + input := service.CreateTagInput{ + Name: body.Name, + Color: body.Color, + Icon: body.Icon, + } + + tag, err := h.services.Tag.CreateTag(r.Context(), userID, input) + if err != nil { + SendJSONError(w, err, http.StatusInternalServerError, h.logger) + return + } + + SendJSONResponse(w, http.StatusCreated, mapDomainTagToApi(tag), h.logger) +} + +func (h *ApiHandler) ListUserTags(w http.ResponseWriter, r *http.Request) { + userID, err := GetUserIDFromContext(r.Context()) + if err != nil { + SendJSONError(w, err, http.StatusInternalServerError, h.logger) + return + } + + tags, err := h.services.Tag.ListUserTags(r.Context(), userID) + if err != nil { + SendJSONError(w, err, http.StatusInternalServerError, h.logger) + return + } + + apiTags := make([]models.Tag, len(tags)) + for i, tag := range tags { + apiTags[i] = *mapDomainTagToApi(&tag) + } + + SendJSONResponse(w, http.StatusOK, apiTags, h.logger) +} + +func (h *ApiHandler) GetTagById(w http.ResponseWriter, r *http.Request, tagId openapi_types.UUID) { + userID, err := GetUserIDFromContext(r.Context()) + if err != nil { + SendJSONError(w, err, http.StatusInternalServerError, h.logger) + return + } + domainTagID := uuid.UUID(tagId) + + tag, err := h.services.Tag.GetTagByID(r.Context(), domainTagID, userID) + if err != nil { + SendJSONError(w, err, http.StatusInternalServerError, h.logger) + return + } + SendJSONResponse(w, http.StatusOK, mapDomainTagToApi(tag), h.logger) +} + +func (h *ApiHandler) UpdateTagById(w http.ResponseWriter, r *http.Request, tagId openapi_types.UUID) { + userID, err := GetUserIDFromContext(r.Context()) + if err != nil { + SendJSONError(w, err, http.StatusInternalServerError, h.logger) + return + } + domainTagID := uuid.UUID(tagId) + + var body models.UpdateTagRequest + if !parseAndValidateBody(w, r, &body, h.logger) { + return + } + + input := service.UpdateTagInput{ + Name: body.Name, + Color: body.Color, + Icon: body.Icon, + } + + tag, err := h.services.Tag.UpdateTag(r.Context(), domainTagID, userID, input) + if err != nil { + SendJSONError(w, err, http.StatusInternalServerError, h.logger) + return + } + + SendJSONResponse(w, http.StatusOK, mapDomainTagToApi(tag), h.logger) +} + +func (h *ApiHandler) DeleteTagById(w http.ResponseWriter, r *http.Request, tagId openapi_types.UUID) { + userID, err := GetUserIDFromContext(r.Context()) + if err != nil { + SendJSONError(w, err, http.StatusInternalServerError, h.logger) + return + } + domainTagID := uuid.UUID(tagId) + + err = h.services.Tag.DeleteTag(r.Context(), domainTagID, userID) + if err != nil { + SendJSONError(w, err, http.StatusInternalServerError, h.logger) + return + } + + w.WriteHeader(http.StatusNoContent) +} + +// --- Todo Handlers --- + +func (h *ApiHandler) CreateTodo(w http.ResponseWriter, r *http.Request) { + userID, err := GetUserIDFromContext(r.Context()) + if err != nil { + SendJSONError(w, err, http.StatusInternalServerError, h.logger) + return + } + + var body models.CreateTodoRequest + if !parseAndValidateBody(w, r, &body, h.logger) { + return + } + + var domainTagIDs []uuid.UUID + if body.TagIds != nil { + domainTagIDs = make([]uuid.UUID, len(*body.TagIds)) + for i, apiID := range *body.TagIds { + domainTagIDs[i] = uuid.UUID(apiID) + } + } else { + domainTagIDs = []uuid.UUID{} + } + + input := service.CreateTodoInput{ + Title: body.Title, + Description: body.Description, + Deadline: body.Deadline, + TagIDs: domainTagIDs, + } + if body.Status != nil { + domainStatus := domain.TodoStatus(*body.Status) + input.Status = &domainStatus + } + + todo, err := h.services.Todo.CreateTodo(r.Context(), userID, input) + if err != nil { + SendJSONError(w, err, http.StatusInternalServerError, h.logger) + return + } + apiTodo := mapDomainTodoToApi(todo) + SendJSONResponse(w, http.StatusCreated, apiTodo, h.logger) +} + +func (h *ApiHandler) ListTodos(w http.ResponseWriter, r *http.Request, params ListTodosParams) { + userID, err := GetUserIDFromContext(r.Context()) + if err != nil { + SendJSONError(w, err, http.StatusInternalServerError, h.logger) + return + } + + input := service.ListTodosInput{ + Limit: 20, + Offset: 0, + } + if params.Limit != nil { + input.Limit = *params.Limit + } + if params.Offset != nil { + input.Offset = *params.Offset + } + if params.Status != nil { + domainStatus := domain.TodoStatus(*params.Status) + input.Status = &domainStatus + } + if params.TagId != nil { + input.TagID = params.TagId + } + if params.DeadlineBefore != nil { + input.DeadlineBefore = params.DeadlineBefore + } + if params.DeadlineAfter != nil { + input.DeadlineAfter = params.DeadlineAfter + } + + todos, err := h.services.Todo.ListUserTodos(r.Context(), userID, input) + if err != nil { + SendJSONError(w, err, http.StatusInternalServerError, h.logger) + return + } + + apiTodos := make([]models.Todo, len(todos)) + for i, todo := range todos { + apiTodos[i] = *mapDomainTodoToApi(&todo) + } + + SendJSONResponse(w, http.StatusOK, apiTodos, h.logger) +} + +func (h *ApiHandler) GetTodoById(w http.ResponseWriter, r *http.Request, todoId openapi_types.UUID) { + userID, err := GetUserIDFromContext(r.Context()) + if err != nil { + SendJSONError(w, err, http.StatusInternalServerError, h.logger) + return + } + + todo, err := h.services.Todo.GetTodoByID(r.Context(), todoId, userID) + if err != nil { + SendJSONError(w, err, http.StatusInternalServerError, h.logger) + return + } + + SendJSONResponse(w, http.StatusOK, mapDomainTodoToApi(todo), h.logger) +} + +func (h *ApiHandler) UpdateTodoById(w http.ResponseWriter, r *http.Request, todoId openapi_types.UUID) { + userID, err := GetUserIDFromContext(r.Context()) + if err != nil { + SendJSONError(w, err, http.StatusInternalServerError, h.logger) + return + } + domainTodoID := uuid.UUID(todoId) + + var body models.UpdateTodoRequest + if !parseAndValidateBody(w, r, &body, h.logger) { + return + } + + input := service.UpdateTodoInput{ + Title: body.Title, + Description: body.Description, + Deadline: body.Deadline, + } + + if body.Status != nil { + domainStatus := domain.TodoStatus(*body.Status) + input.Status = &domainStatus + } + + if body.TagIds != nil { + domainTagIDs := make([]uuid.UUID, len(*body.TagIds)) + for i, apiID := range *body.TagIds { + domainTagIDs[i] = uuid.UUID(apiID) + } + input.TagIDs = &domainTagIDs + } + if body.Attachments != nil { + input.Attachments = body.Attachments + } + + todo, err := h.services.Todo.UpdateTodo(r.Context(), domainTodoID, userID, input) + if err != nil { + SendJSONError(w, err, http.StatusInternalServerError, h.logger) + return + } + + SendJSONResponse(w, http.StatusOK, mapDomainTodoToApi(todo), h.logger) +} + +func (h *ApiHandler) DeleteTodoById(w http.ResponseWriter, r *http.Request, todoId openapi_types.UUID) { + userID, err := GetUserIDFromContext(r.Context()) + if err != nil { + SendJSONError(w, err, http.StatusInternalServerError, h.logger) + return + } + + err = h.services.Todo.DeleteTodo(r.Context(), todoId, userID) + if err != nil { + SendJSONError(w, err, http.StatusInternalServerError, h.logger) + return + } + + w.WriteHeader(http.StatusNoContent) +} + +// --- Subtask Handlers --- + +func (h *ApiHandler) CreateSubtaskForTodo(w http.ResponseWriter, r *http.Request, todoId openapi_types.UUID) { + userID, err := GetUserIDFromContext(r.Context()) + if err != nil { + SendJSONError(w, err, http.StatusInternalServerError, h.logger) + return + } + + var body models.CreateSubtaskRequest + if !parseAndValidateBody(w, r, &body, h.logger) { + return + } + input := service.CreateSubtaskInput{ + Description: body.Description, + } + + subtask, err := h.services.Todo.CreateSubtask(r.Context(), todoId, userID, input) + if err != nil { + SendJSONError(w, err, http.StatusInternalServerError, h.logger) + return + } + + SendJSONResponse(w, http.StatusCreated, mapDomainSubtaskToApi(subtask), h.logger) +} + +func (h *ApiHandler) ListSubtasksForTodo(w http.ResponseWriter, r *http.Request, todoId openapi_types.UUID) { + userID, err := GetUserIDFromContext(r.Context()) + if err != nil { + SendJSONError(w, err, http.StatusInternalServerError, h.logger) + return + } + + subtasks, err := h.services.Todo.ListSubtasks(r.Context(), todoId, userID) + if err != nil { + SendJSONError(w, err, http.StatusInternalServerError, h.logger) + return + } + + apiSubtasks := make([]models.Subtask, len(subtasks)) + for i, st := range subtasks { + apiSubtasks[i] = *mapDomainSubtaskToApi(&st) + } + + SendJSONResponse(w, http.StatusOK, apiSubtasks, h.logger) +} + +func (h *ApiHandler) UpdateSubtaskById(w http.ResponseWriter, r *http.Request, todoId openapi_types.UUID, subtaskId openapi_types.UUID) { + userID, err := GetUserIDFromContext(r.Context()) + if err != nil { + SendJSONError(w, err, http.StatusInternalServerError, h.logger) + return + } + + var body models.UpdateSubtaskRequest + if !parseAndValidateBody(w, r, &body, h.logger) { + return + } + + input := service.UpdateSubtaskInput{ + Description: body.Description, + Completed: body.Completed, + } + + subtask, err := h.services.Todo.UpdateSubtask(r.Context(), todoId, subtaskId, userID, input) + if err != nil { + SendJSONError(w, err, http.StatusInternalServerError, h.logger) + return + } + + SendJSONResponse(w, http.StatusOK, mapDomainSubtaskToApi(subtask), h.logger) +} + +func (h *ApiHandler) DeleteSubtaskById(w http.ResponseWriter, r *http.Request, todoId openapi_types.UUID, subtaskId openapi_types.UUID) { + userID, err := GetUserIDFromContext(r.Context()) + if err != nil { + SendJSONError(w, err, http.StatusInternalServerError, h.logger) + return + } + + err = h.services.Todo.DeleteSubtask(r.Context(), todoId, subtaskId, userID) + if err != nil { + SendJSONError(w, err, http.StatusInternalServerError, h.logger) + return + } + + w.WriteHeader(http.StatusNoContent) +} + +// --- Attachment Handlers --- + +func (h *ApiHandler) UploadTodoAttachment(w http.ResponseWriter, r *http.Request, todoId openapi_types.UUID) { + userID, err := GetUserIDFromContext(r.Context()) + if err != nil { + SendJSONError(w, err, http.StatusInternalServerError, h.logger) + return + } + + err = r.ParseMultipartForm(10 << 20) + if err != nil { + SendJSONError(w, fmt.Errorf("failed to parse multipart form: %w", domain.ErrBadRequest), http.StatusBadRequest, h.logger) + return + } + + file, handler, err := r.FormFile("file") + if err != nil { + SendJSONError(w, fmt.Errorf("error retrieving the file from form-data: %w", domain.ErrBadRequest), http.StatusBadRequest, h.logger) + return + } + defer file.Close() + + fileName := handler.Filename + fileSize := handler.Size + + attachmentInfo, err := h.services.Todo.AddAttachment(r.Context(), todoId, userID, fileName, fileSize, file) + if err != nil { + SendJSONError(w, err, http.StatusInternalServerError, h.logger) + return + } + + SendJSONResponse(w, http.StatusCreated, mapDomainAttachmentInfoToApi(attachmentInfo), h.logger) +} + +func (h *ApiHandler) DeleteTodoAttachment(w http.ResponseWriter, r *http.Request, todoId openapi_types.UUID, attachmentId string) { + userID, err := GetUserIDFromContext(r.Context()) + if err != nil { + SendJSONError(w, err, http.StatusInternalServerError, h.logger) + return + } + + err = h.services.Todo.DeleteAttachment(r.Context(), todoId, userID, attachmentId) + if err != nil { + SendJSONError(w, err, http.StatusInternalServerError, h.logger) + return + } + + w.WriteHeader(http.StatusNoContent) +} diff --git a/backend/internal/api/middleware.go b/backend/internal/api/middleware.go new file mode 100644 index 0000000..1626753 --- /dev/null +++ b/backend/internal/api/middleware.go @@ -0,0 +1,108 @@ +package api + +import ( + "context" + "errors" + "fmt" + "log/slog" + "net/http" + "strings" + + "github.com/Sosokker/todolist-backend/internal/config" + "github.com/Sosokker/todolist-backend/internal/domain" + "github.com/Sosokker/todolist-backend/internal/service" + "github.com/google/uuid" +) + +type contextKey string + +const UserIDKey contextKey = "userID" + +var publicPaths = map[string]bool{ + "/auth/signup": true, + "/auth/login": true, + "/auth/google/login": true, + "/auth/google/callback": true, +} + +func AuthMiddleware(authService service.AuthService, cfg *config.Config) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestPath := r.URL.Path + basePath := cfg.Server.BasePath + relativePath := strings.TrimPrefix(requestPath, basePath) + + if _, isPublic := publicPaths[relativePath]; isPublic { + slog.DebugContext(r.Context(), "Public path accessed, skipping auth", "path", requestPath) + next.ServeHTTP(w, r) + return + } + + tokenString := extractToken(r, cfg) + if tokenString == "" { + slog.WarnContext(r.Context(), "Authentication failed: missing token", "path", requestPath) + SendJSONError(w, domain.ErrUnauthorized, http.StatusUnauthorized, slog.Default()) + return + } + + claims, err := authService.ValidateJWT(tokenString) + if err != nil { + slog.WarnContext(r.Context(), "Authentication failed: invalid token", "error", err, "path", requestPath) + SendJSONError(w, domain.ErrUnauthorized, http.StatusUnauthorized, slog.Default()) + return + } + + if claims.ID == uuid.Nil { + slog.ErrorContext(r.Context(), "Authentication failed: Nil User ID in token claims", "path", requestPath) + SendJSONError(w, domain.ErrUnauthorized, http.StatusUnauthorized, slog.Default()) + return + } + + ctx := context.WithValue(r.Context(), UserIDKey, claims.ID) + slog.DebugContext(ctx, "Authentication successful", "userId", claims.ID) + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} + +func extractToken(r *http.Request, cfg *config.Config) string { + bearerToken := r.Header.Get("Authorization") + if parts := strings.Split(bearerToken, " "); len(parts) == 2 && strings.ToLower(parts[0]) == "bearer" { + slog.DebugContext(r.Context(), "Token found in Authorization header") + return parts[1] + } + + cookie, err := r.Cookie(cfg.JWT.CookieName) + if err == nil { + slog.DebugContext(r.Context(), "Token found in cookie", "cookieName", cfg.JWT.CookieName) + return cookie.Value + } + if !errors.Is(err, http.ErrNoCookie) { + slog.WarnContext(r.Context(), "Error reading auth cookie", "error", err, "cookieName", cfg.JWT.CookieName) + } else { + slog.DebugContext(r.Context(), "No token found in header or cookie") + } + + return "" +} + +func GetUserIDFromContext(ctx context.Context) (uuid.UUID, error) { + userIDVal := ctx.Value(UserIDKey) + if userIDVal == nil { + slog.ErrorContext(ctx, "User ID not found in context. Middleware might not have run or failed.") + return uuid.Nil, fmt.Errorf("user ID not found in context: %w", domain.ErrInternalServer) + } + + userID, ok := userIDVal.(uuid.UUID) + if !ok { + slog.ErrorContext(ctx, "User ID in context has unexpected type", "type", fmt.Sprintf("%T", userIDVal)) + return uuid.Nil, fmt.Errorf("user ID in context has unexpected type: %w", domain.ErrInternalServer) + } + + if userID == uuid.Nil { + slog.ErrorContext(ctx, "Nil User ID found in context after authentication") + return uuid.Nil, fmt.Errorf("invalid user ID (nil) found in context: %w", domain.ErrInternalServer) + } + + return userID, nil +} diff --git a/backend/internal/api/oapi-codegen.cfg.yaml b/backend/internal/api/oapi-codegen.cfg.yaml new file mode 100644 index 0000000..c29d931 --- /dev/null +++ b/backend/internal/api/oapi-codegen.cfg.yaml @@ -0,0 +1,17 @@ +package: api + +generate: + chi-server: + output: openapi_generated.go + strict-server: true + + types: + output: openapi_types.go + + models: + package: models + output: openapi_models_generated.go + + strict-server: + package: api + output: openapi_strict_server_generated.go \ No newline at end of file diff --git a/backend/internal/api/openapi_types.go b/backend/internal/api/openapi_types.go new file mode 100644 index 0000000..f8ea3e3 --- /dev/null +++ b/backend/internal/api/openapi_types.go @@ -0,0 +1,317 @@ +// Package api provides primitives to interact with the openapi HTTP API. +// +// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.4.1 DO NOT EDIT. +package api + +import ( + "time" + + openapi_types "github.com/oapi-codegen/runtime/types" +) + +const ( + BearerAuthScopes = "BearerAuth.Scopes" + CookieAuthScopes = "CookieAuth.Scopes" +) + +// Defines values for CreateTodoRequestStatus. +const ( + CreateTodoRequestStatusCompleted CreateTodoRequestStatus = "completed" + CreateTodoRequestStatusInProgress CreateTodoRequestStatus = "in-progress" + CreateTodoRequestStatusPending CreateTodoRequestStatus = "pending" +) + +// Defines values for TodoStatus. +const ( + TodoStatusCompleted TodoStatus = "completed" + TodoStatusInProgress TodoStatus = "in-progress" + TodoStatusPending TodoStatus = "pending" +) + +// Defines values for UpdateTodoRequestStatus. +const ( + UpdateTodoRequestStatusCompleted UpdateTodoRequestStatus = "completed" + UpdateTodoRequestStatusInProgress UpdateTodoRequestStatus = "in-progress" + UpdateTodoRequestStatusPending UpdateTodoRequestStatus = "pending" +) + +// Defines values for ListTodosParamsStatus. +const ( + ListTodosParamsStatusCompleted ListTodosParamsStatus = "completed" + ListTodosParamsStatusInProgress ListTodosParamsStatus = "in-progress" + ListTodosParamsStatusPending ListTodosParamsStatus = "pending" +) + +// CreateSubtaskRequest Data required to create a new Subtask. +type CreateSubtaskRequest struct { + Description string `json:"description"` +} + +// CreateTagRequest Data required to create a new Tag. +type CreateTagRequest struct { + // Color Optional color code (e.g., + Color *string `json:"color"` + + // Icon Optional icon identifier. + Icon *string `json:"icon"` + + // Name Name of the tag. Must be unique for the user. + Name string `json:"name"` +} + +// CreateTodoRequest Data required to create a new Todo item. +type CreateTodoRequest struct { + Deadline *time.Time `json:"deadline"` + Description *string `json:"description"` + Status *CreateTodoRequestStatus `json:"status,omitempty"` + + // TagIds Optional list of existing Tag IDs to associate with the new Todo. IDs must belong to the user. + TagIds *[]openapi_types.UUID `json:"tagIds,omitempty"` + Title string `json:"title"` +} + +// CreateTodoRequestStatus defines model for CreateTodoRequest.Status. +type CreateTodoRequestStatus string + +// Error Standard error response format. +type Error struct { + // Code HTTP status code or application-specific code. + Code int32 `json:"code"` + + // Message Detailed error message. + Message string `json:"message"` +} + +// FileUploadResponse Response after successfully uploading a file. +type FileUploadResponse struct { + // ContentType MIME type of the uploaded file. + ContentType string `json:"contentType"` + + // FileId Unique identifier for the uploaded file. + FileId string `json:"fileId"` + + // FileName Original name of the uploaded file. + FileName string `json:"fileName"` + + // FileUrl URL to access the uploaded file. + FileUrl string `json:"fileUrl"` + + // Size Size of the uploaded file in bytes. + Size int64 `json:"size"` +} + +// LoginRequest Data required for logging in via email/password. +type LoginRequest struct { + Email openapi_types.Email `json:"email"` + Password *string `json:"password,omitempty"` +} + +// LoginResponse Response containing the JWT access token for API clients. For browser clients, a cookie is typically set instead. +type LoginResponse struct { + // AccessToken JWT access token. + AccessToken string `json:"accessToken"` + + // TokenType Type of the token (always Bearer). + TokenType string `json:"tokenType"` +} + +// SignupRequest Data required for signing up a new user via email/password. +type SignupRequest struct { + Email openapi_types.Email `json:"email"` + Password *string `json:"password,omitempty"` + Username string `json:"username"` +} + +// Subtask Represents a subtask associated with a Todo item. +type Subtask struct { + // Completed Whether the subtask is completed. + Completed bool `json:"completed"` + CreatedAt *time.Time `json:"createdAt,omitempty"` + + // Description Description of the subtask. + Description string `json:"description"` + Id *openapi_types.UUID `json:"id,omitempty"` + + // TodoId The ID of the parent Todo item. + TodoId *openapi_types.UUID `json:"todoId,omitempty"` + UpdatedAt *time.Time `json:"updatedAt,omitempty"` +} + +// Tag Represents a user-defined tag for organizing Todos. +type Tag struct { + // Color Optional color associated with the tag. + Color *string `json:"color"` + CreatedAt *time.Time `json:"createdAt,omitempty"` + + // Icon Optional identifier for an icon associated with the tag (e.g., 'briefcase', 'home'). Frontend maps this to actual icon display. + Icon *string `json:"icon"` + Id *openapi_types.UUID `json:"id,omitempty"` + + // Name Name of the tag (e.g., "Work", "Personal"). Must be unique per user. + Name string `json:"name"` + UpdatedAt *time.Time `json:"updatedAt,omitempty"` + + // UserId The ID of the user who owns this Tag. + UserId *openapi_types.UUID `json:"userId,omitempty"` +} + +// Todo Represents a Todo item. +type Todo struct { + // Attachments List of identifiers (e.g., URLs or IDs) for attached files/images. Managed via upload/update endpoints. + Attachments []string `json:"attachments"` + CreatedAt *time.Time `json:"createdAt,omitempty"` + + // Deadline Optional deadline for the Todo item. + Deadline *time.Time `json:"deadline"` + + // Description Optional detailed description of the Todo. + Description *string `json:"description"` + Id *openapi_types.UUID `json:"id,omitempty"` + + // Status Current status of the Todo item. + Status TodoStatus `json:"status"` + + // Subtasks List of subtasks associated with this Todo. Usually fetched/managed via subtask endpoints. + Subtasks *[]Subtask `json:"subtasks,omitempty"` + + // TagIds List of IDs of Tags associated with this Todo. + TagIds []openapi_types.UUID `json:"tagIds"` + + // Title The main title or task of the Todo. + Title string `json:"title"` + UpdatedAt *time.Time `json:"updatedAt,omitempty"` + + // UserId The ID of the user who owns this Todo. + UserId *openapi_types.UUID `json:"userId,omitempty"` +} + +// TodoStatus Current status of the Todo item. +type TodoStatus string + +// UpdateSubtaskRequest Data for updating an existing Subtask. Both fields are optional. +type UpdateSubtaskRequest struct { + Completed *bool `json:"completed,omitempty"` + Description *string `json:"description,omitempty"` +} + +// UpdateTagRequest Data for updating an existing Tag. All fields are optional. +type UpdateTagRequest struct { + // Color New color code. + Color *string `json:"color"` + + // Icon New icon identifier. + Icon *string `json:"icon"` + + // Name New name for the tag. Must be unique for the user. + Name *string `json:"name,omitempty"` +} + +// UpdateTodoRequest Data for updating an existing Todo item. All fields are optional for partial updates. +type UpdateTodoRequest struct { + // Attachments Replace the existing list of attachment identifiers. Use upload/delete endpoints for managing actual files. + Attachments *[]string `json:"attachments,omitempty"` + Deadline *time.Time `json:"deadline"` + Description *string `json:"description"` + Status *UpdateTodoRequestStatus `json:"status,omitempty"` + + // TagIds Replace the existing list of associated Tag IDs. IDs must belong to the user. + TagIds *[]openapi_types.UUID `json:"tagIds,omitempty"` + Title *string `json:"title,omitempty"` +} + +// UpdateTodoRequestStatus defines model for UpdateTodoRequest.Status. +type UpdateTodoRequestStatus string + +// UpdateUserRequest Data for updating user details. +type UpdateUserRequest struct { + Username *string `json:"username,omitempty"` +} + +// User Represents a registered user. +type User struct { + CreatedAt *time.Time `json:"createdAt,omitempty"` + Email openapi_types.Email `json:"email"` + + // EmailVerified Indicates if the user's email has been verified (e.g., via OAuth or email confirmation). + EmailVerified *bool `json:"emailVerified,omitempty"` + Id *openapi_types.UUID `json:"id,omitempty"` + UpdatedAt *time.Time `json:"updatedAt,omitempty"` + Username string `json:"username"` +} + +// BadRequest Standard error response format. +type BadRequest = Error + +// Conflict Standard error response format. +type Conflict = Error + +// Forbidden Standard error response format. +type Forbidden = Error + +// InternalServerError Standard error response format. +type InternalServerError = Error + +// NotFound Standard error response format. +type NotFound = Error + +// Unauthorized Standard error response format. +type Unauthorized = Error + +// ListTodosParams defines parameters for ListTodos. +type ListTodosParams struct { + // Status Filter Todos by status. + Status *ListTodosParamsStatus `form:"status,omitempty" json:"status,omitempty"` + + // TagId Filter Todos by a specific Tag ID. + TagId *openapi_types.UUID `form:"tagId,omitempty" json:"tagId,omitempty"` + + // DeadlineBefore Filter Todos with deadline before this date/time. + DeadlineBefore *time.Time `form:"deadline_before,omitempty" json:"deadline_before,omitempty"` + + // DeadlineAfter Filter Todos with deadline after this date/time. + DeadlineAfter *time.Time `form:"deadline_after,omitempty" json:"deadline_after,omitempty"` + + // Limit Maximum number of Todos to return. + Limit *int `form:"limit,omitempty" json:"limit,omitempty"` + + // Offset Number of Todos to skip for pagination. + Offset *int `form:"offset,omitempty" json:"offset,omitempty"` +} + +// ListTodosParamsStatus defines parameters for ListTodos. +type ListTodosParamsStatus string + +// UploadTodoAttachmentMultipartBody defines parameters for UploadTodoAttachment. +type UploadTodoAttachmentMultipartBody struct { + File openapi_types.File `json:"file"` +} + +// LoginUserApiJSONRequestBody defines body for LoginUserApi for application/json ContentType. +type LoginUserApiJSONRequestBody = LoginRequest + +// SignupUserApiJSONRequestBody defines body for SignupUserApi for application/json ContentType. +type SignupUserApiJSONRequestBody = SignupRequest + +// CreateTagJSONRequestBody defines body for CreateTag for application/json ContentType. +type CreateTagJSONRequestBody = CreateTagRequest + +// UpdateTagByIdJSONRequestBody defines body for UpdateTagById for application/json ContentType. +type UpdateTagByIdJSONRequestBody = UpdateTagRequest + +// CreateTodoJSONRequestBody defines body for CreateTodo for application/json ContentType. +type CreateTodoJSONRequestBody = CreateTodoRequest + +// UpdateTodoByIdJSONRequestBody defines body for UpdateTodoById for application/json ContentType. +type UpdateTodoByIdJSONRequestBody = UpdateTodoRequest + +// UploadTodoAttachmentMultipartRequestBody defines body for UploadTodoAttachment for multipart/form-data ContentType. +type UploadTodoAttachmentMultipartRequestBody UploadTodoAttachmentMultipartBody + +// CreateSubtaskForTodoJSONRequestBody defines body for CreateSubtaskForTodo for application/json ContentType. +type CreateSubtaskForTodoJSONRequestBody = CreateSubtaskRequest + +// UpdateSubtaskByIdJSONRequestBody defines body for UpdateSubtaskById for application/json ContentType. +type UpdateSubtaskByIdJSONRequestBody = UpdateSubtaskRequest + +// UpdateCurrentUserJSONRequestBody defines body for UpdateCurrentUser for application/json ContentType. +type UpdateCurrentUserJSONRequestBody = UpdateUserRequest diff --git a/backend/internal/auth/jwt.go b/backend/internal/auth/jwt.go new file mode 100644 index 0000000..1c7423c --- /dev/null +++ b/backend/internal/auth/jwt.go @@ -0,0 +1,11 @@ +package auth + +import ( + "github.com/golang-jwt/jwt/v5" + "github.com/google/uuid" +) + +type Claims struct { + UserID uuid.UUID `json:"user_id"` + jwt.RegisteredClaims +} diff --git a/backend/internal/auth/oauth.go b/backend/internal/auth/oauth.go new file mode 100644 index 0000000..abbacef --- /dev/null +++ b/backend/internal/auth/oauth.go @@ -0,0 +1,103 @@ +package auth + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + + "github.com/Sosokker/todolist-backend/internal/config" + "golang.org/x/oauth2" + googleOAuth "golang.org/x/oauth2/google" +) + +// GoogleUserInfo holds user details fetched from Google. +type GoogleUserInfo struct { + ID string `json:"id"` // The unique Google ID + Email string `json:"email"` // The user's email address + VerifiedEmail bool `json:"verified_email"` // Whether Google has verified the email + Name string `json:"name"` // User's full name + GivenName string `json:"given_name"` // First name + FamilyName string `json:"family_name"` // Last name + Picture string `json:"picture"` // URL to profile picture + Locale string `json:"locale"` // User's locale (e.g., "en") +} + +// OAuthProvider defines the interface for OAuth operations. +type OAuthProvider interface { + GetAuthCodeURL(state string) string + ExchangeCode(ctx context.Context, code string) (*oauth2.Token, error) + FetchUserInfo(ctx context.Context, token *oauth2.Token) (*GoogleUserInfo, error) + GetOAuth2Config() *oauth2.Config // Expose underlying config if needed +} + +// googleOAuthProvider implements OAuthProvider for Google. +type googleOAuthProvider struct { + cfg *oauth2.Config +} + +// NewGoogleOAuthProvider creates a new provider instance configured for Google. +func NewGoogleOAuthProvider(appCfg *config.Config) OAuthProvider { + return &googleOAuthProvider{ + cfg: &oauth2.Config{ + ClientID: appCfg.OAuth.Google.ClientID, + ClientSecret: appCfg.OAuth.Google.ClientSecret, + RedirectURL: appCfg.OAuth.Google.RedirectURL, + Scopes: appCfg.OAuth.Google.Scopes, + Endpoint: googleOAuth.Endpoint, + }, + } +} + +// GetAuthCodeURL generates the URL for Google's consent page. +func (g *googleOAuthProvider) GetAuthCodeURL(state string) string { + // Add options like AccessTypeOffline to get a refresh token, + authURL := g.cfg.AuthCodeURL(state, oauth2.AccessTypeOffline /*, oauth2.ApprovalForce, oauth2.SetAuthURLParam("prompt", "select_account") */) + return authURL +} + +// ExchangeCode exchanges the authorization code for an access token and refresh token. +func (g *googleOAuthProvider) ExchangeCode(ctx context.Context, code string) (*oauth2.Token, error) { + token, err := g.cfg.Exchange(ctx, code) + if err != nil { + return nil, fmt.Errorf("failed to exchange google auth code '%s': %w", code, err) + } + if !token.Valid() { + return nil, fmt.Errorf("exchanged token is invalid") + } + return token, nil +} + +// FetchUserInfo uses the access token to get user details from Google's UserInfo endpoint. +func (g *googleOAuthProvider) FetchUserInfo(ctx context.Context, token *oauth2.Token) (*GoogleUserInfo, error) { + client := g.cfg.Client(ctx, token) + + userInfoURL := "https://www.googleapis.com/oauth2/v3/userinfo" // v3 is common + resp, err := client.Get(userInfoURL) + if err != nil { + return nil, fmt.Errorf("failed to request google user info from %s: %w", userInfoURL, err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + bodyBytes, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("google user info request failed with status %d: %s", resp.StatusCode, string(bodyBytes)) + } + + var userInfo GoogleUserInfo + if err := json.NewDecoder(resp.Body).Decode(&userInfo); err != nil { + return nil, fmt.Errorf("failed to decode google user info response: %w", err) + } + + if userInfo.ID == "" || userInfo.Email == "" { + return nil, fmt.Errorf("invalid user info received from google (missing ID or Email)") + } + + return &userInfo, nil +} + +// GetOAuth2Config returns the underlying oauth2.Config object. +func (g *googleOAuthProvider) GetOAuth2Config() *oauth2.Config { + return g.cfg +} diff --git a/backend/internal/auth/state.go b/backend/internal/auth/state.go new file mode 100644 index 0000000..8160d61 --- /dev/null +++ b/backend/internal/auth/state.go @@ -0,0 +1,72 @@ +package auth + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "strings" + "time" +) + +const ( + StateCookieName = "oauth_state" + StateSeparator = "." + StateExpiry = 10 * time.Minute +) + +var ErrInvalidStateFormat = errors.New("invalid state format") +var ErrInvalidStateMAC = errors.New("invalid state MAC (tampered?)") +var ErrStateExpired = errors.New("state expired") + +// signState generates a timestamped and HMAC-signed state string. +// Format: .. +func SignState(stateValue string, secretKey []byte) string { + if len(secretKey) == 0 { + // Should not happen in production if configured correctly + panic("OAuth state signing secret cannot be empty") + } + timestamp := time.Now().Unix() + message := fmt.Sprintf("%s%s%d", stateValue, StateSeparator, timestamp) + + mac := hmac.New(sha256.New, secretKey) + mac.Write([]byte(message)) + signature := hex.EncodeToString(mac.Sum(nil)) + + return fmt.Sprintf("%s%s%s", message, StateSeparator, signature) +} + +// verifyAndExtractState checks the signature and expiry, returning the original state value. +func VerifyAndExtractState(signedState string, secretKey []byte) (string, error) { + if len(secretKey) == 0 { + panic("OAuth state signing secret cannot be empty") + } + parts := strings.Split(signedState, StateSeparator) + if len(parts) != 3 { + return "", ErrInvalidStateFormat + } + + originalState := parts[0] + timestampStr := parts[1] + receivedSignature := parts[2] + + message := fmt.Sprintf("%s%s%s", originalState, StateSeparator, timestampStr) + mac := hmac.New(sha256.New, secretKey) + mac.Write([]byte(message)) + expectedSignature := hex.EncodeToString(mac.Sum(nil)) + + if !hmac.Equal([]byte(receivedSignature), []byte(expectedSignature)) { + return "", ErrInvalidStateMAC + } + + var timestamp int64 + if _, err := fmt.Sscan(timestampStr, ×tamp); err != nil { + return "", fmt.Errorf("invalid timestamp in state: %w", ErrInvalidStateFormat) + } + if time.Since(time.Unix(timestamp, 0)) > StateExpiry { + return "", ErrStateExpired + } + + return originalState, nil +} diff --git a/backend/internal/cache/cache.go b/backend/internal/cache/cache.go new file mode 100644 index 0000000..17cd13f --- /dev/null +++ b/backend/internal/cache/cache.go @@ -0,0 +1,55 @@ +package cache + +import ( + "context" + "log/slog" + "time" + + "github.com/Sosokker/todolist-backend/internal/config" + gocache "github.com/patrickmn/go-cache" +) + +// Cache defines the interface for a caching layer +type Cache interface { + Get(ctx context.Context, key string) (interface{}, bool) + Set(ctx context.Context, key string, value interface{}, duration time.Duration) + Delete(ctx context.Context, key string) +} + +// memoryCache is an in-memory implementation of the Cache interface +type memoryCache struct { + client *gocache.Cache + logger *slog.Logger +} + +// NewMemoryCache creates a new in-memory cache +func NewMemoryCache(cfg config.CacheConfig, logger *slog.Logger) Cache { + c := gocache.New(cfg.DefaultExpiration, cfg.CleanupInterval) + logger.Info("In-memory cache initialized", + "defaultExpiration", cfg.DefaultExpiration, + "cleanupInterval", cfg.CleanupInterval) + return &memoryCache{ + client: c, + logger: logger, + } +} + +func (m *memoryCache) Get(ctx context.Context, key string) (interface{}, bool) { + val, found := m.client.Get(key) + if found { + m.logger.DebugContext(ctx, "Cache hit", "key", key) + } else { + m.logger.DebugContext(ctx, "Cache miss", "key", key) + } + return val, found +} + +func (m *memoryCache) Set(ctx context.Context, key string, value interface{}, duration time.Duration) { + m.logger.DebugContext(ctx, "Setting cache", "key", key, "duration", duration) + m.client.Set(key, value, duration) // duration=0 means use default, -1 means never expire (DefaultExpiration) +} + +func (m *memoryCache) Delete(ctx context.Context, key string) { + m.logger.DebugContext(ctx, "Deleting cache", "key", key) + m.client.Delete(key) +} diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go new file mode 100644 index 0000000..ead765e --- /dev/null +++ b/backend/internal/config/config.go @@ -0,0 +1,135 @@ +package config + +import ( + "log/slog" + "strings" + "time" + + "github.com/spf13/viper" +) + +type Config struct { + Server ServerConfig + Database DatabaseConfig + Log LogConfig + JWT JWTConfig + OAuth OAuthConfig + Cache CacheConfig + Storage StorageConfig +} + +type ServerConfig struct { + Port int `mapstructure:"port"` + ReadTimeout time.Duration `mapstructure:"readTimeout"` + WriteTimeout time.Duration `mapstructure:"writeTimeout"` + IdleTimeout time.Duration `mapstructure:"idleTimeout"` + BasePath string `mapstructure:"basePath"` +} + +type StorageConfig struct { + Type string `mapstructure:"type"` // "local", "gcs" + Local LocalStorageConfig `mapstructure:"local"` + GCS GCSStorageConfig `mapstructure:"gcs"` +} + +type LocalStorageConfig struct { + Path string `mapstructure:"path"` +} + +type GCSStorageConfig struct { + BucketName string `mapstructure:"bucketName"` + CredentialsFile string `mapstructure:"credentialsFile"` + BaseDir string `mapstructure:"baseDir"` +} + +type DatabaseConfig struct { + URL string `mapstructure:"url"` +} + +type LogConfig struct { + Level string `mapstructure:"level"` + Format string `mapstructure:"format"` +} + +type JWTConfig struct { + Secret string `mapstructure:"secret"` + ExpiryMinutes int `mapstructure:"expiryMinutes"` + CookieName string `mapstructure:"cookieName"` + CookieDomain string `mapstructure:"cookieDomain"` + CookiePath string `mapstructure:"cookiePath"` + CookieSecure bool `mapstructure:"cookieSecure"` + CookieHttpOnly bool `mapstructure:"cookieHttpOnly"` + CookieSameSite string `mapstructure:"cookieSameSite"` // None, Lax, Strict +} + +type GoogleOAuthConfig struct { + ClientID string `mapstructure:"clientId"` + ClientSecret string `mapstructure:"clientSecret"` + RedirectURL string `mapstructure:"redirectUrl"` + Scopes []string `mapstructure:"scopes"` + StateSecret string `mapstructure:"stateSecret"` // For signing state cookie +} + +type OAuthConfig struct { + Google GoogleOAuthConfig `mapstructure:"google"` +} + +type CacheConfig struct { + DefaultExpiration time.Duration `mapstructure:"defaultExpiration"` + CleanupInterval time.Duration `mapstructure:"cleanupInterval"` +} + +func LoadConfig(path string) (*Config, error) { + viper.SetConfigName("config") // name of config file (without extension) + viper.SetConfigType("yaml") // or viper.SetConfigType("YAML") + viper.AddConfigPath(path) // optionally look for config in the working directory or specified path + viper.AddConfigPath(".") // look for config in the working directory + + viper.AutomaticEnv() + viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) + + // Set defaults + viper.SetDefault("server.port", 8080) + viper.SetDefault("server.readTimeout", 15*time.Second) + viper.SetDefault("server.writeTimeout", 15*time.Second) + viper.SetDefault("server.idleTimeout", 60*time.Second) + viper.SetDefault("log.level", "info") + viper.SetDefault("log.format", "json") + viper.SetDefault("jwt.expiryMinutes", 60) + viper.SetDefault("jwt.cookieName", "jwt_token") + viper.SetDefault("jwt.cookieHttpOnly", true) + viper.SetDefault("jwt.cookieSameSite", "Lax") + viper.SetDefault("cache.defaultExpiration", 5*time.Minute) + viper.SetDefault("cache.cleanupInterval", 10*time.Minute) + viper.SetDefault("storage.type", "local") // Default to local storage + viper.SetDefault("storage.local.path", "./uploads") + + err := viper.ReadInConfig() + if err != nil { + slog.Warn("Error reading config file, using defaults/env vars", "error", err) + if _, ok := err.(viper.ConfigFileNotFoundError); !ok { + return nil, err + } + } + + var cfg Config + err = viper.Unmarshal(&cfg) + if err != nil { + return nil, err + } + + if cfg.JWT.Secret == "" || strings.Contains(cfg.JWT.Secret, "unsafe") { + slog.Warn("JWT_SECRET environment variable not set or is using unsafe default. THIS IS INSECURE FOR PRODUCTION.") + } + if cfg.OAuth.Google.ClientID == "" || strings.Contains(cfg.OAuth.Google.ClientID, "_ENV") { + slog.Warn("OAUTH_GOOGLE_CLIENTID environment variable not set or is using placeholder.") + } + if cfg.OAuth.Google.ClientSecret == "" || strings.Contains(cfg.OAuth.Google.ClientSecret, "_ENV") { + slog.Warn("OAUTH_GOOGLE_CLIENTSECRET environment variable not set or is using placeholder.") + } + if cfg.OAuth.Google.StateSecret == "" || strings.Contains(cfg.OAuth.Google.StateSecret, "unsafe") { + slog.Warn("OAUTH_GOOGLE_STATESECRET environment variable not set or is using unsafe default. THIS IS INSECURE FOR PRODUCTION.") + } + + return &cfg, nil +} diff --git a/backend/internal/domain/attachment.go b/backend/internal/domain/attachment.go new file mode 100644 index 0000000..a565cdb --- /dev/null +++ b/backend/internal/domain/attachment.go @@ -0,0 +1,10 @@ +package domain + +// Attachment info returned by AddAttachment +type AttachmentInfo struct { + FileID string `json:"fileId"` + FileName string `json:"fileName"` + FileURL string `json:"fileUrl"` + ContentType string `json:"contentType"` + Size int64 `json:"size"` +} diff --git a/backend/internal/domain/errors.go b/backend/internal/domain/errors.go new file mode 100644 index 0000000..6426d3b --- /dev/null +++ b/backend/internal/domain/errors.go @@ -0,0 +1,13 @@ +package domain + +import "errors" + +var ( + ErrNotFound = errors.New("resource not found") + ErrForbidden = errors.New("user does not have permission") + ErrBadRequest = errors.New("invalid input") + ErrConflict = errors.New("resource conflict (e.g., duplicate)") + ErrUnauthorized = errors.New("authentication required or failed") + ErrInternalServer = errors.New("internal server error") + ErrValidation = errors.New("validation failed") +) diff --git a/backend/internal/domain/subtask.go b/backend/internal/domain/subtask.go new file mode 100644 index 0000000..8788261 --- /dev/null +++ b/backend/internal/domain/subtask.go @@ -0,0 +1,16 @@ +package domain + +import ( + "time" + + "github.com/google/uuid" +) + +type Subtask struct { + ID uuid.UUID `json:"id"` + TodoID uuid.UUID `json:"todoId"` + Description string `json:"description"` + Completed bool `json:"completed"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} diff --git a/backend/internal/domain/tag.go b/backend/internal/domain/tag.go new file mode 100644 index 0000000..c81652b --- /dev/null +++ b/backend/internal/domain/tag.go @@ -0,0 +1,17 @@ +package domain + +import ( + "time" + + "github.com/google/uuid" +) + +type Tag struct { + ID uuid.UUID `json:"id"` + UserID uuid.UUID `json:"userId"` + Name string `json:"name"` + Color *string `json:"color"` + Icon *string `json:"icon"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} diff --git a/backend/internal/domain/todo.go b/backend/internal/domain/todo.go new file mode 100644 index 0000000..b3bf4d0 --- /dev/null +++ b/backend/internal/domain/todo.go @@ -0,0 +1,45 @@ +package domain + +import ( + "database/sql" + "time" + + "github.com/google/uuid" +) + +type TodoStatus string + +const ( + StatusPending TodoStatus = "pending" + StatusInProgress TodoStatus = "in-progress" + StatusCompleted TodoStatus = "completed" +) + +type Todo struct { + ID uuid.UUID `json:"id"` + UserID uuid.UUID `json:"userId"` + Title string `json:"title"` + Description *string `json:"description"` // Nullable + Status TodoStatus `json:"status"` + Deadline *time.Time `json:"deadline"` // Nullable + TagIDs []uuid.UUID `json:"tagIds"` // Populated after fetching + Tags []Tag `json:"-"` // Can hold full tag objects if needed, loaded separately + Attachments []string `json:"attachments"` // Stores identifiers (e.g., file IDs or URLs) + Subtasks []Subtask `json:"subtasks"` // Populated after fetching + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +func NullStringToStringPtr(ns sql.NullString) *string { + if ns.Valid { + return &ns.String + } + return nil +} + +func NullTimeToTimePtr(nt sql.NullTime) *time.Time { + if nt.Valid { + return &nt.Time + } + return nil +} diff --git a/backend/internal/domain/user.go b/backend/internal/domain/user.go new file mode 100644 index 0000000..2652e6d --- /dev/null +++ b/backend/internal/domain/user.go @@ -0,0 +1,18 @@ +package domain + +import ( + "time" + + "github.com/google/uuid" +) + +type User struct { + ID uuid.UUID `json:"id"` + Username string `json:"username"` + Email string `json:"email"` + PasswordHash string `json:"-"` + EmailVerified bool `json:"emailVerified"` + GoogleID *string `json:"-"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} diff --git a/backend/internal/repository/db.go b/backend/internal/repository/db.go new file mode 100644 index 0000000..0c77952 --- /dev/null +++ b/backend/internal/repository/db.go @@ -0,0 +1,42 @@ +package repository + +import ( + "context" + "fmt" + "log/slog" + "time" + + "github.com/Sosokker/todolist-backend/internal/config" + "github.com/jackc/pgx/v5/pgxpool" +) + +func NewConnectionPool(cfg config.DatabaseConfig) (*pgxpool.Pool, error) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + poolConfig, err := pgxpool.ParseConfig(cfg.URL) + if err != nil { + return nil, fmt.Errorf("unable to parse database URL: %w", err) + } + + // Configure pool settings + // poolConfig.MaxConns = 10 + // poolConfig.MinConns = 2 + // poolConfig.MaxConnIdleTime = 5 * time.Minute + // poolConfig.MaxConnLifetime = 1 * time.Hour + // poolConfig.HealthCheckPeriod = 1 * time.Minute + + pool, err := pgxpool.NewWithConfig(ctx, poolConfig) + if err != nil { + return nil, fmt.Errorf("unable to create connection pool: %w", err) + } + + err = pool.Ping(ctx) + if err != nil { + pool.Close() + return nil, fmt.Errorf("unable to connect to database: %w", err) + } + + slog.Info("Database connection pool established") + return pool, nil +} diff --git a/backend/internal/repository/interfaces.go b/backend/internal/repository/interfaces.go new file mode 100644 index 0000000..e48e980 --- /dev/null +++ b/backend/internal/repository/interfaces.go @@ -0,0 +1,98 @@ +package repository + +import ( + "context" + "time" + + "github.com/Sosokker/todolist-backend/internal/domain" + db "github.com/Sosokker/todolist-backend/internal/repository/sqlc/generated" + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgxpool" +) + +// Common arguments for list methods +type ListParams struct { + Limit int + Offset int +} + +type UserRepository interface { + Create(ctx context.Context, user *domain.User) (*domain.User, error) + GetByID(ctx context.Context, id uuid.UUID) (*domain.User, error) + GetByEmail(ctx context.Context, email string) (*domain.User, error) + GetByGoogleID(ctx context.Context, googleID string) (*domain.User, error) + Update(ctx context.Context, id uuid.UUID, updateData *domain.User) (*domain.User, error) + Delete(ctx context.Context, id uuid.UUID) error +} + +type TagRepository interface { + Create(ctx context.Context, tag *domain.Tag) (*domain.Tag, error) + GetByID(ctx context.Context, id, userID uuid.UUID) (*domain.Tag, error) + GetByIDs(ctx context.Context, ids []uuid.UUID, userID uuid.UUID) ([]domain.Tag, error) + ListByUser(ctx context.Context, userID uuid.UUID) ([]domain.Tag, error) + Update(ctx context.Context, id, userID uuid.UUID, updateData *domain.Tag) (*domain.Tag, error) + Delete(ctx context.Context, id, userID uuid.UUID) error +} + +type ListTodosParams struct { + UserID uuid.UUID + Status *domain.TodoStatus + TagID *uuid.UUID + DeadlineBefore *time.Time + DeadlineAfter *time.Time + ListParams // Embed pagination +} + +type TodoRepository interface { + Create(ctx context.Context, todo *domain.Todo) (*domain.Todo, error) + GetByID(ctx context.Context, id, userID uuid.UUID) (*domain.Todo, error) + ListByUser(ctx context.Context, params ListTodosParams) ([]domain.Todo, error) + Update(ctx context.Context, id, userID uuid.UUID, updateData *domain.Todo) (*domain.Todo, error) + Delete(ctx context.Context, id, userID uuid.UUID) error + // Tag associations + AddTag(ctx context.Context, todoID, tagID uuid.UUID) error + RemoveTag(ctx context.Context, todoID, tagID uuid.UUID) error + SetTags(ctx context.Context, todoID uuid.UUID, tagIDs []uuid.UUID) error + GetTags(ctx context.Context, todoID uuid.UUID) ([]domain.Tag, error) + // Attachment associations (using simple string array) + AddAttachment(ctx context.Context, todoID, userID uuid.UUID, attachmentID string) error + RemoveAttachment(ctx context.Context, todoID, userID uuid.UUID, attachmentID string) error + SetAttachments(ctx context.Context, todoID, userID uuid.UUID, attachmentIDs []string) error +} + +type SubtaskRepository interface { + Create(ctx context.Context, subtask *domain.Subtask) (*domain.Subtask, error) + GetByID(ctx context.Context, id, userID uuid.UUID) (*domain.Subtask, error) + ListByTodo(ctx context.Context, todoID, userID uuid.UUID) ([]domain.Subtask, error) + Update(ctx context.Context, id, userID uuid.UUID, updateData *domain.Subtask) (*domain.Subtask, error) + Delete(ctx context.Context, id, userID uuid.UUID) error + GetParentTodoID(ctx context.Context, id uuid.UUID) (uuid.UUID, error) +} + +// Transactioner interface allows services to run operations within a DB transaction +type Transactioner interface { + BeginTx(ctx context.Context) (*db.Queries, error) +} + +// RepositoryRegistry bundles all repositories together, often useful for dependency injection +type RepositoryRegistry struct { + UserRepo UserRepository + TagRepo TagRepository + TodoRepo TodoRepository + SubtaskRepo SubtaskRepository + *db.Queries + Pool *pgxpool.Pool +} + +// NewRepositoryRegistry creates a new registry +func NewRepositoryRegistry(pool *pgxpool.Pool) *RepositoryRegistry { + queries := db.New(pool) + return &RepositoryRegistry{ + UserRepo: NewPgxUserRepository(queries), + TagRepo: NewPgxTagRepository(queries), + TodoRepo: NewPgxTodoRepository(queries, pool), + SubtaskRepo: NewPgxSubtaskRepository(queries), + Queries: queries, + Pool: pool, + } +} diff --git a/backend/internal/repository/sqlc/queries/attachments.sql b/backend/internal/repository/sqlc/queries/attachments.sql new file mode 100644 index 0000000..e2f846b --- /dev/null +++ b/backend/internal/repository/sqlc/queries/attachments.sql @@ -0,0 +1,17 @@ +-- name: CreateAttachment :one +INSERT INTO attachments (todo_id, user_id, file_name, storage_path, content_type, size) +VALUES ($1, $2, $3, $4, $5, $6) +RETURNING *; + +-- name: GetAttachmentByID :one +SELECT * FROM attachments +WHERE id = $1 AND user_id = $2 LIMIT 1; + +-- name: ListAttachmentsForTodo :many +SELECT * FROM attachments +WHERE todo_id = $1 AND user_id = $2 +ORDER BY uploaded_at ASC; + +-- name: DeleteAttachment :exec +DELETE FROM attachments +WHERE id = $1 AND user_id = $2; \ No newline at end of file diff --git a/backend/internal/repository/sqlc/queries/subtasks.sql b/backend/internal/repository/sqlc/queries/subtasks.sql new file mode 100644 index 0000000..2949106 --- /dev/null +++ b/backend/internal/repository/sqlc/queries/subtasks.sql @@ -0,0 +1,36 @@ +-- name: CreateSubtask :one +INSERT INTO subtasks (todo_id, description, completed) +VALUES ($1, $2, $3) +RETURNING *; + +-- name: GetSubtaskByID :one +-- We need to join to check ownership via the parent todo +SELECT s.* FROM subtasks s +JOIN todos t ON s.todo_id = t.id +WHERE s.id = $1 AND t.user_id = $2 LIMIT 1; + +-- name: ListSubtasksForTodo :many +SELECT s.* FROM subtasks s +JOIN todos t ON s.todo_id = t.id +WHERE s.todo_id = $1 AND t.user_id = $2 +ORDER BY s.created_at ASC; + +-- name: UpdateSubtask :one +-- Need to join to check ownership before updating +UPDATE subtasks s +SET + description = COALESCE(sqlc.narg(description), s.description), + completed = COALESCE(sqlc.narg(completed), s.completed) +FROM todos t -- Include todos table in FROM clause for WHERE condition +WHERE s.id = $1 AND s.todo_id = t.id AND t.user_id = $2 +RETURNING s.*; -- Return columns from subtasks (aliased as s) + +-- name: DeleteSubtask :exec +-- Need owner check before deleting +DELETE FROM subtasks s +USING todos t +WHERE s.id = $1 AND s.todo_id = t.id AND t.user_id = $2; + +-- name: GetTodoIDForSubtask :one +-- Helper to get parent todo ID for authorization checks in service layer if needed +SELECT todo_id FROM subtasks WHERE id = $1; \ No newline at end of file diff --git a/backend/internal/repository/sqlc/queries/tags.sql b/backend/internal/repository/sqlc/queries/tags.sql new file mode 100644 index 0000000..a916c10 --- /dev/null +++ b/backend/internal/repository/sqlc/queries/tags.sql @@ -0,0 +1,30 @@ +-- name: CreateTag :one +INSERT INTO tags (user_id, name, color, icon) +VALUES ($1, $2, $3, $4) +RETURNING *; + +-- name: GetTagByID :one +SELECT * FROM tags +WHERE id = $1 AND user_id = $2 LIMIT 1; + +-- name: ListUserTags :many +SELECT * FROM tags +WHERE user_id = $1 +ORDER BY created_at DESC; + +-- name: UpdateTag :one +UPDATE tags +SET + name = COALESCE(sqlc.narg(name), name), + color = sqlc.narg(color), -- Allow setting color to NULL + icon = sqlc.narg(icon) -- Allow setting icon to NULL +WHERE id = $1 AND user_id = $2 +RETURNING *; + +-- name: DeleteTag :exec +DELETE FROM tags +WHERE id = $1 AND user_id = $2; + +-- name: GetTagsByIDs :many +SELECT * FROM tags +WHERE id = ANY(sqlc.arg(tag_ids)::uuid[]) AND user_id = $1; \ No newline at end of file diff --git a/backend/internal/repository/sqlc/queries/todo_tags.sql b/backend/internal/repository/sqlc/queries/todo_tags.sql new file mode 100644 index 0000000..4c5891d --- /dev/null +++ b/backend/internal/repository/sqlc/queries/todo_tags.sql @@ -0,0 +1,18 @@ +-- name: AddTagToTodo :exec +INSERT INTO todo_tags (todo_id, tag_id) +VALUES ($1, $2) +ON CONFLICT (todo_id, tag_id) DO NOTHING; -- Ignore if already exists + +-- name: RemoveTagFromTodo :exec +DELETE FROM todo_tags +WHERE todo_id = $1 AND tag_id = $2; + +-- name: RemoveAllTagsFromTodo :exec +DELETE FROM todo_tags +WHERE todo_id = $1; + +-- name: GetTagsForTodo :many +SELECT t.* +FROM tags t +JOIN todo_tags tt ON t.id = tt.tag_id +WHERE tt.todo_id = $1; \ No newline at end of file diff --git a/backend/internal/repository/sqlc/queries/todos.sql b/backend/internal/repository/sqlc/queries/todos.sql new file mode 100644 index 0000000..43034b6 --- /dev/null +++ b/backend/internal/repository/sqlc/queries/todos.sql @@ -0,0 +1,47 @@ +-- name: CreateTodo :one +INSERT INTO todos (user_id, title, description, status, deadline) +VALUES ($1, $2, $3, $4, $5) +RETURNING *; + +-- name: GetTodoByID :one +SELECT * FROM todos +WHERE id = $1 AND user_id = $2 LIMIT 1; + +-- name: ListUserTodos :many +SELECT t.* FROM todos t +LEFT JOIN todo_tags tt ON t.id = tt.todo_id +WHERE + t.user_id = sqlc.arg('user_id') -- Use sqlc.arg for required params + AND (sqlc.narg('status_filter')::todo_status IS NULL OR t.status = sqlc.narg('status_filter')) + AND (sqlc.narg('tag_id_filter')::uuid IS NULL OR tt.tag_id = sqlc.narg('tag_id_filter')) + AND (sqlc.narg('deadline_before_filter')::timestamptz IS NULL OR t.deadline < sqlc.narg('deadline_before_filter')) + AND (sqlc.narg('deadline_after_filter')::timestamptz IS NULL OR t.deadline > sqlc.narg('deadline_after_filter')) +GROUP BY t.id -- Still needed due to LEFT JOIN potentially multiplying rows if a todo has multiple tags +ORDER BY t.created_at DESC -- Or your desired order +LIMIT sqlc.arg('limit') +OFFSET sqlc.arg('offset'); + +-- name: UpdateTodo :one +UPDATE todos +SET + title = COALESCE(sqlc.narg(title), title), + description = sqlc.narg(description), -- Allow setting description to NULL + status = COALESCE(sqlc.narg(status), status), + deadline = sqlc.narg(deadline), -- Allow setting deadline to NULL + attachments = COALESCE(sqlc.narg(attachments), attachments) +WHERE id = $1 AND user_id = $2 +RETURNING *; + +-- name: DeleteTodo :exec +DELETE FROM todos +WHERE id = $1 AND user_id = $2; + +-- name: AddAttachmentToTodo :exec +UPDATE todos +SET attachments = array_append(attachments, $1) +WHERE id = $2 AND user_id = $3; + +-- name: RemoveAttachmentFromTodo :exec +UPDATE todos +SET attachments = array_remove(attachments, $1) +WHERE id = $2 AND user_id = $3; \ No newline at end of file diff --git a/backend/internal/repository/sqlc/queries/users.sql b/backend/internal/repository/sqlc/queries/users.sql new file mode 100644 index 0000000..5d52919 --- /dev/null +++ b/backend/internal/repository/sqlc/queries/users.sql @@ -0,0 +1,31 @@ +-- name: CreateUser :one +INSERT INTO users (username, email, password_hash, email_verified, google_id) +VALUES ($1, $2, $3, $4, $5) +RETURNING *; + +-- name: GetUserByID :one +SELECT * FROM users +WHERE id = $1 LIMIT 1; + +-- name: GetUserByEmail :one +SELECT * FROM users +WHERE email = $1 LIMIT 1; + +-- name: GetUserByGoogleID :one +SELECT * FROM users +WHERE google_id = $1 LIMIT 1; + +-- name: UpdateUser :one +UPDATE users +SET + username = COALESCE(sqlc.narg(username), username), + email = COALESCE(sqlc.narg(email), email), + email_verified = COALESCE(sqlc.narg(email_verified), email_verified), + google_id = COALESCE(sqlc.narg(google_id), google_id) + -- password_hash update should be handled separately if needed +WHERE id = $1 +RETURNING *; + +-- name: DeleteUser :exec +DELETE FROM users +WHERE id = $1; \ No newline at end of file diff --git a/backend/internal/repository/subtask_repo.go b/backend/internal/repository/subtask_repo.go new file mode 100644 index 0000000..64163e0 --- /dev/null +++ b/backend/internal/repository/subtask_repo.go @@ -0,0 +1,154 @@ +package repository + +import ( + "context" + "database/sql" + "errors" + "fmt" + + "github.com/Sosokker/todolist-backend/internal/domain" + db "github.com/Sosokker/todolist-backend/internal/repository/sqlc/generated" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + "github.com/jackc/pgx/v5/pgtype" +) + +type pgxSubtaskRepository struct { + q *db.Queries +} + +func NewPgxSubtaskRepository(queries *db.Queries) SubtaskRepository { + return &pgxSubtaskRepository{q: queries} +} + +// --- Mapping functions --- +func mapDbSubtaskToDomain(d db.Subtask) *domain.Subtask { + return &domain.Subtask{ + ID: d.ID, + TodoID: d.TodoID, + Description: d.Description, + Completed: d.Completed, + CreatedAt: d.CreatedAt, + UpdatedAt: d.UpdatedAt, + } +} + +func mapDbSubtasksToDomain(ds []db.Subtask) []domain.Subtask { + out := make([]domain.Subtask, len(ds)) + for i, d := range ds { + out[i] = *mapDbSubtaskToDomain(d) + } + return out +} + +// --- Repository Methods --- + +func (r *pgxSubtaskRepository) Create( + ctx context.Context, + subtask *domain.Subtask, +) (*domain.Subtask, error) { + params := db.CreateSubtaskParams{ + TodoID: subtask.TodoID, + Description: subtask.Description, + Completed: subtask.Completed, + } + d, err := r.q.CreateSubtask(ctx, params) + if err != nil { + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) && pgErr.Code == "23503" { + return nil, fmt.Errorf("parent todo %s not found: %w", subtask.TodoID, domain.ErrBadRequest) + } + return nil, fmt.Errorf("failed to create subtask: %w", err) + } + return mapDbSubtaskToDomain(d), nil +} + +func (r *pgxSubtaskRepository) GetByID( + ctx context.Context, + id, userID uuid.UUID, +) (*domain.Subtask, error) { + d, err := r.q.GetSubtaskByID(ctx, db.GetSubtaskByIDParams{ + ID: id, + UserID: userID, + }) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, domain.ErrNotFound + } + return nil, fmt.Errorf("failed to get subtask: %w", err) + } + return mapDbSubtaskToDomain(d), nil +} + +func (r *pgxSubtaskRepository) ListByTodo( + ctx context.Context, + todoID, userID uuid.UUID, +) ([]domain.Subtask, error) { + ds, err := r.q.ListSubtasksForTodo(ctx, db.ListSubtasksForTodoParams{ + TodoID: todoID, + UserID: userID, + }) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return []domain.Subtask{}, nil + } + return nil, fmt.Errorf("failed to list subtasks: %w", err) + } + return mapDbSubtasksToDomain(ds), nil +} + +func (r *pgxSubtaskRepository) Update( + ctx context.Context, + id, userID uuid.UUID, + updateData *domain.Subtask, +) (*domain.Subtask, error) { + params := db.UpdateSubtaskParams{ + ID: id, + UserID: userID, + Description: sql.NullString{ + String: updateData.Description, + Valid: updateData.Description != "", + }, + Completed: pgtype.Bool{ + Bool: updateData.Completed, + Valid: true, + }, + } + + d, err := r.q.UpdateSubtask(ctx, params) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, domain.ErrNotFound + } + return nil, fmt.Errorf("failed to update subtask: %w", err) + } + return mapDbSubtaskToDomain(d), nil +} + +func (r *pgxSubtaskRepository) Delete( + ctx context.Context, + id, userID uuid.UUID, +) error { + if err := r.q.DeleteSubtask(ctx, db.DeleteSubtaskParams{ + ID: id, + UserID: userID, + }); err != nil { + return fmt.Errorf("failed to delete subtask: %w", err) + } + return nil +} + +func (r *pgxSubtaskRepository) GetParentTodoID( + ctx context.Context, + id uuid.UUID, +) (uuid.UUID, error) { + todoID, err := r.q.GetTodoIDForSubtask(ctx, id) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return uuid.Nil, domain.ErrNotFound + } + return uuid.Nil, fmt.Errorf("failed to get parent todo id: %w", err) + } + return todoID, nil +} diff --git a/backend/internal/repository/tag_repo.go b/backend/internal/repository/tag_repo.go new file mode 100644 index 0000000..e8155c5 --- /dev/null +++ b/backend/internal/repository/tag_repo.go @@ -0,0 +1,163 @@ +package repository + +import ( + "context" + "database/sql" + "errors" + "fmt" + + "github.com/Sosokker/todolist-backend/internal/domain" + db "github.com/Sosokker/todolist-backend/internal/repository/sqlc/generated" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + "github.com/jackc/pgx/v5/pgtype" +) + +type pgxTagRepository struct { + q *db.Queries +} + +func NewPgxTagRepository(queries *db.Queries) TagRepository { + return &pgxTagRepository{q: queries} +} + +func pgTextFromPtr(s *string) pgtype.Text { + if s == nil { + return pgtype.Text{} + } + return pgtype.Text{String: *s, Valid: true} +} + +func nullStringFromText(t pgtype.Text) sql.NullString { + return sql.NullString{String: t.String, Valid: t.Valid} +} + +func mapDbTagToDomainTag(dbTag db.Tag) *domain.Tag { + return &domain.Tag{ + ID: dbTag.ID, + UserID: dbTag.UserID, + Name: dbTag.Name, + Color: domain.NullStringToStringPtr(nullStringFromText(dbTag.Color)), + Icon: domain.NullStringToStringPtr(nullStringFromText(dbTag.Icon)), + CreatedAt: dbTag.CreatedAt, + UpdatedAt: dbTag.UpdatedAt, + } +} + +func mapDbTagsToDomainTags(dbTags []db.Tag) []domain.Tag { + tags := make([]domain.Tag, len(dbTags)) + for i, t := range dbTags { + tags[i] = *mapDbTagToDomainTag(t) + } + return tags +} + +func (r *pgxTagRepository) Create( + ctx context.Context, + tag *domain.Tag, +) (*domain.Tag, error) { + params := db.CreateTagParams{ + UserID: tag.UserID, + Name: tag.Name, + Color: pgTextFromPtr(tag.Color), + Icon: pgTextFromPtr(tag.Icon), + } + + dbTag, err := r.q.CreateTag(ctx, params) + if err != nil { + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) && pgErr.Code == "23505" { + return nil, fmt.Errorf("tag name '%s' already exists: %w", tag.Name, domain.ErrConflict) + } + return nil, fmt.Errorf("failed to create tag: %w", err) + } + return mapDbTagToDomainTag(dbTag), nil +} + +func (r *pgxTagRepository) GetByID( + ctx context.Context, + id, userID uuid.UUID, +) (*domain.Tag, error) { + dbTag, err := r.q.GetTagByID(ctx, db.GetTagByIDParams{ID: id, UserID: userID}) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, domain.ErrNotFound + } + return nil, fmt.Errorf("failed to get tag by id: %w", err) + } + return mapDbTagToDomainTag(dbTag), nil +} + +func (r *pgxTagRepository) GetByIDs( + ctx context.Context, + ids []uuid.UUID, + userID uuid.UUID, +) ([]domain.Tag, error) { + if len(ids) == 0 { + return []domain.Tag{}, nil + } + dbTags, err := r.q.GetTagsByIDs(ctx, db.GetTagsByIDsParams{ + UserID: userID, + TagIds: ids, + }) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return []domain.Tag{}, nil + } + return nil, fmt.Errorf("failed to get tags by ids: %w", err) + } + return mapDbTagsToDomainTags(dbTags), nil +} + +func (r *pgxTagRepository) ListByUser( + ctx context.Context, + userID uuid.UUID, +) ([]domain.Tag, error) { + dbTags, err := r.q.ListUserTags(ctx, userID) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return []domain.Tag{}, nil + } + return nil, fmt.Errorf("failed to list user tags: %w", err) + } + return mapDbTagsToDomainTags(dbTags), nil +} + +func (r *pgxTagRepository) Update( + ctx context.Context, + id, userID uuid.UUID, + updateData *domain.Tag, +) (*domain.Tag, error) { + if _, err := r.GetByID(ctx, id, userID); err != nil { + return nil, err + } + + params := db.UpdateTagParams{ + ID: id, + UserID: userID, + Name: pgtype.Text{String: updateData.Name, Valid: true}, + Color: pgTextFromPtr(updateData.Color), + Icon: pgTextFromPtr(updateData.Icon), + } + + dbTag, err := r.q.UpdateTag(ctx, params) + if err != nil { + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) && pgErr.Code == "23505" { + return nil, fmt.Errorf("tag name '%s' already exists: %w", updateData.Name, domain.ErrConflict) + } + return nil, fmt.Errorf("failed to update tag: %w", err) + } + return mapDbTagToDomainTag(dbTag), nil +} + +func (r *pgxTagRepository) Delete( + ctx context.Context, + id, userID uuid.UUID, +) error { + if err := r.q.DeleteTag(ctx, db.DeleteTagParams{ID: id, UserID: userID}); err != nil { + return fmt.Errorf("failed to delete tag: %w", err) + } + return nil +} diff --git a/backend/internal/repository/todo_repo.go b/backend/internal/repository/todo_repo.go new file mode 100644 index 0000000..922ffd5 --- /dev/null +++ b/backend/internal/repository/todo_repo.go @@ -0,0 +1,329 @@ +package repository + +import ( + "context" + "database/sql" + "errors" + "fmt" + + "github.com/Sosokker/todolist-backend/internal/domain" + db "github.com/Sosokker/todolist-backend/internal/repository/sqlc/generated" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + "github.com/jackc/pgx/v5/pgtype" + "github.com/jackc/pgx/v5/pgxpool" +) + +type pgxTodoRepository struct { + q *db.Queries + pool *pgxpool.Pool + // Consider adding a TagRepository dependency here for batch loading if needed +} + +func NewPgxTodoRepository(queries *db.Queries, pool *pgxpool.Pool) TodoRepository { + return &pgxTodoRepository{q: queries, pool: pool} +} + +// --- Mapping functions --- + +func mapDbTodoToDomain(dbTodo db.Todo) *domain.Todo { + return &domain.Todo{ + ID: dbTodo.ID, + UserID: dbTodo.UserID, + Title: dbTodo.Title, + Description: domain.NullStringToStringPtr(dbTodo.Description), + Status: domain.TodoStatus(dbTodo.Status), + Deadline: dbTodo.Deadline, + Attachments: dbTodo.Attachments, + CreatedAt: dbTodo.CreatedAt, + UpdatedAt: dbTodo.UpdatedAt, + } +} + +func mapDbTagToDomain(dbTag db.Tag) domain.Tag { + return domain.Tag{ + ID: dbTag.ID, + UserID: dbTag.UserID, + Name: dbTag.Name, + Color: domain.NullStringToStringPtr(nullStringFromText(dbTag.Color)), + Icon: domain.NullStringToStringPtr(nullStringFromText(dbTag.Icon)), + CreatedAt: dbTag.CreatedAt, + UpdatedAt: dbTag.UpdatedAt, + } +} + +// ――― TodoRepository methods ――― + +func (r *pgxTodoRepository) Create( + ctx context.Context, + todo *domain.Todo, +) (*domain.Todo, error) { + params := db.CreateTodoParams{ + UserID: todo.UserID, + Title: todo.Title, + Description: sql.NullString{String: derefString(todo.Description), Valid: todo.Description != nil}, + Status: db.TodoStatus(todo.Status), + Deadline: todo.Deadline, + } + dbTodo, err := r.q.CreateTodo(ctx, params) + if err != nil { + return nil, fmt.Errorf("failed to create todo: %w", err) + } + return mapDbTodoToDomain(dbTodo), nil +} + +func (r *pgxTodoRepository) GetByID( + ctx context.Context, + id, userID uuid.UUID, +) (*domain.Todo, error) { + dbTodo, err := r.q.GetTodoByID(ctx, db.GetTodoByIDParams{ID: id, UserID: userID}) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, domain.ErrNotFound + } + return nil, fmt.Errorf("failed to get todo: %w", err) + } + return mapDbTodoToDomain(dbTodo), nil +} + +func (r *pgxTodoRepository) ListByUser( + ctx context.Context, + params ListTodosParams, +) ([]domain.Todo, error) { + + sqlcParams := db.ListUserTodosParams{ + UserID: params.UserID, + Limit: int32(params.Limit), + Offset: int32(params.Offset), + StatusFilter: db.NullTodoStatus{Valid: false}, + TagIDFilter: pgtype.UUID{Valid: false}, + DeadlineBeforeFilter: nil, + DeadlineAfterFilter: nil, + } + + if params.Status != nil { + sqlcParams.StatusFilter = db.NullTodoStatus{ + TodoStatus: db.TodoStatus(*params.Status), + Valid: true, + } + } + + if params.TagID != nil { + sqlcParams.TagIDFilter = pgtype.UUID{ + Bytes: *params.TagID, + Valid: true, + } + } + + if params.DeadlineBefore != nil { + sqlcParams.DeadlineBeforeFilter = params.DeadlineBefore + } + + if params.DeadlineAfter != nil { + sqlcParams.DeadlineAfterFilter = params.DeadlineAfter + } + + // Call the regenerated sqlc function with the correctly populated struct + dbTodos, err := r.q.ListUserTodos(ctx, sqlcParams) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return []domain.Todo{}, nil + } + return nil, fmt.Errorf("failed to list todos: %w", err) + } + + todos := make([]domain.Todo, len(dbTodos)) + for i, t := range dbTodos { + mappedTodo := mapDbTodoToDomain(t) + if mappedTodo != nil { + todos[i] = *mappedTodo + } else { + return nil, fmt.Errorf("failed to map database todo at index %d", i) + } + } + return todos, nil +} + +func (r *pgxTodoRepository) Update( + ctx context.Context, + id, userID uuid.UUID, + updateData *domain.Todo, +) (*domain.Todo, error) { + if _, err := r.GetByID(ctx, id, userID); err != nil { + return nil, err + } + + params := db.UpdateTodoParams{ + ID: id, + UserID: userID, + Title: pgtype.Text{String: updateData.Title, Valid: true}, + Description: sql.NullString{String: derefString(updateData.Description), Valid: updateData.Description != nil}, + Status: db.NullTodoStatus{TodoStatus: db.TodoStatus(updateData.Status), Valid: true}, + Deadline: updateData.Deadline, + Attachments: updateData.Attachments, + } + + dbTodo, err := r.q.UpdateTodo(ctx, params) + if err != nil { + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) && pgErr.Code == "23503" { + return nil, fmt.Errorf("foreign key violation: %w", domain.ErrBadRequest) + } + return nil, fmt.Errorf("failed to update todo: %w", err) + } + return mapDbTodoToDomain(dbTodo), nil +} + +func (r *pgxTodoRepository) Delete( + ctx context.Context, + id, userID uuid.UUID, +) error { + if err := r.q.DeleteTodo(ctx, db.DeleteTodoParams{ID: id, UserID: userID}); err != nil { + return fmt.Errorf("failed to delete todo: %w", err) + } + return nil +} + +// --- Tag Associations --- + +func (r *pgxTodoRepository) AddTag( + ctx context.Context, + todoID, tagID uuid.UUID, +) error { + if err := r.q.AddTagToTodo(ctx, db.AddTagToTodoParams{TodoID: todoID, TagID: tagID}); err != nil { + return fmt.Errorf("failed to add tag: %w", err) + } + return nil +} + +func (r *pgxTodoRepository) RemoveTag( + ctx context.Context, + todoID, tagID uuid.UUID, +) error { + if err := r.q.RemoveTagFromTodo(ctx, db.RemoveTagFromTodoParams{TodoID: todoID, TagID: tagID}); err != nil { + return fmt.Errorf("failed to remove tag: %w", err) + } + return nil +} + +func (r *pgxTodoRepository) SetTags( + ctx context.Context, + todoID uuid.UUID, + tagIDs []uuid.UUID, +) error { + tx, err := r.pool.Begin(ctx) + if err != nil { + return fmt.Errorf("begin tx: %w", err) + } + defer tx.Rollback(ctx) + + qtx := r.q.WithTx(tx) + if err := qtx.RemoveAllTagsFromTodo(ctx, todoID); err != nil { + return fmt.Errorf("remove existing tags: %w", err) + } + for _, tID := range tagIDs { + if err := qtx.AddTagToTodo(ctx, db.AddTagToTodoParams{TodoID: todoID, TagID: tID}); err != nil { + return fmt.Errorf("add tag %s: %w", tID, err) + } + } + if err := tx.Commit(ctx); err != nil { + return fmt.Errorf("commit tags tx: %w", err) + } + return nil +} + +func (r *pgxTodoRepository) GetTags( + ctx context.Context, + todoID uuid.UUID, +) ([]domain.Tag, error) { + dbTags, err := r.q.GetTagsForTodo(ctx, todoID) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return []domain.Tag{}, nil + } + return nil, fmt.Errorf("failed to get tags: %w", err) + } + tags := make([]domain.Tag, len(dbTags)) + for i, t := range dbTags { + tags[i] = mapDbTagToDomain(t) + } + return tags, nil +} + +// --- Attachments (String Identifiers in Array) --- + +func (r *pgxTodoRepository) AddAttachment( + ctx context.Context, + todoID, userID uuid.UUID, + attachmentID string, +) error { + if _, err := r.GetByID(ctx, todoID, userID); err != nil { + return err + } + if err := r.q.AddAttachmentToTodo(ctx, db.AddAttachmentToTodoParams{ + ArrayAppend: attachmentID, + ID: todoID, + UserID: userID, + }); err != nil { + return fmt.Errorf("failed to add attachment: %w", err) + } + return nil +} + +func (r *pgxTodoRepository) RemoveAttachment( + ctx context.Context, + todoID, userID uuid.UUID, + attachmentID string, +) error { + if _, err := r.GetByID(ctx, todoID, userID); err != nil { + return err + } + if err := r.q.RemoveAttachmentFromTodo(ctx, db.RemoveAttachmentFromTodoParams{ + ArrayRemove: attachmentID, + ID: todoID, + UserID: userID, + }); err != nil { + return fmt.Errorf("failed to remove attachment: %w", err) + } + return nil +} + +func (r *pgxTodoRepository) SetAttachments( + ctx context.Context, + todoID, userID uuid.UUID, + attachmentIDs []string, +) error { + _, err := r.GetByID(ctx, todoID, userID) + if err != nil { + return err + } + updateParams := db.UpdateTodoParams{ + ID: todoID, + UserID: userID, + Attachments: attachmentIDs, + } + _, err = r.q.UpdateTodo(ctx, updateParams) + if err != nil { + return fmt.Errorf("failed to set attachments using UpdateTodo: %w", err) + } + return nil +} + +// ― Helpers ― + +func derefString(s *string) string { + if s != nil { + return *s + } + return "" +} + +func contains(slice []string, val string) bool { + for _, s := range slice { + if s == val { + return true + } + } + return false +} diff --git a/backend/internal/repository/user_repo.go b/backend/internal/repository/user_repo.go new file mode 100644 index 0000000..d77e5c3 --- /dev/null +++ b/backend/internal/repository/user_repo.go @@ -0,0 +1,156 @@ +package repository + +import ( + "context" + "errors" + + "github.com/Sosokker/todolist-backend/internal/domain" + db "github.com/Sosokker/todolist-backend/internal/repository/sqlc/generated" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + "github.com/jackc/pgx/v5/pgtype" +) + +type pgxUserRepository struct { + q *db.Queries +} + +func NewPgxUserRepository(queries *db.Queries) UserRepository { + return &pgxUserRepository{q: queries} +} + +// mapDbUserToDomain converts a generated User → domain.User +func mapDbUserToDomain(u db.User) *domain.User { + var googleID *string + if u.GoogleID.Valid { + googleID = &u.GoogleID.String + } + return &domain.User{ + ID: u.ID, + Username: u.Username, + Email: u.Email, + PasswordHash: u.PasswordHash, + EmailVerified: u.EmailVerified, + GoogleID: googleID, + CreatedAt: u.CreatedAt, + UpdatedAt: u.UpdatedAt, + } +} + +func (r *pgxUserRepository) Create( + ctx context.Context, + user *domain.User, +) (*domain.User, error) { + var pgGoogleID pgtype.Text + if user.GoogleID != nil { + pgGoogleID = pgtype.Text{String: *user.GoogleID, Valid: true} + } + + dbUser, err := r.q.CreateUser(ctx, db.CreateUserParams{ + Username: user.Username, + Email: user.Email, + PasswordHash: user.PasswordHash, + EmailVerified: user.EmailVerified, + GoogleID: pgGoogleID, + }) + if err != nil { + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) && pgErr.Code == "23505" { + return nil, domain.ErrConflict + } + return nil, err + } + return mapDbUserToDomain(dbUser), nil +} + +func (r *pgxUserRepository) GetByID( + ctx context.Context, + id uuid.UUID, +) (*domain.User, error) { + dbUser, err := r.q.GetUserByID(ctx, id) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, domain.ErrNotFound + } + return nil, err + } + return mapDbUserToDomain(dbUser), nil +} + +func (r *pgxUserRepository) GetByEmail( + ctx context.Context, + email string, +) (*domain.User, error) { + dbUser, err := r.q.GetUserByEmail(ctx, email) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, domain.ErrNotFound + } + return nil, err + } + return mapDbUserToDomain(dbUser), nil +} + +func (r *pgxUserRepository) GetByGoogleID( + ctx context.Context, + googleID string, +) (*domain.User, error) { + pgGoogleID := pgtype.Text{String: googleID, Valid: true} + dbUser, err := r.q.GetUserByGoogleID(ctx, pgGoogleID) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, domain.ErrNotFound + } + return nil, err + } + return mapDbUserToDomain(dbUser), nil +} + +func (r *pgxUserRepository) Update( + ctx context.Context, + id uuid.UUID, + u *domain.User, +) (*domain.User, error) { + if _, err := r.GetByID(ctx, id); err != nil { + return nil, err + } + + var username pgtype.Text + if u.Username != "" { + username = pgtype.Text{String: u.Username, Valid: true} + } + var email pgtype.Text + if u.Email != "" { + email = pgtype.Text{String: u.Email, Valid: true} + } + emailVerified := pgtype.Bool{Bool: u.EmailVerified, Valid: true} + + var pgGoogleID pgtype.Text + if u.GoogleID != nil { + pgGoogleID = pgtype.Text{String: *u.GoogleID, Valid: true} + } + + dbUser, err := r.q.UpdateUser(ctx, db.UpdateUserParams{ + ID: id, + Username: username, + Email: email, + EmailVerified: emailVerified, + GoogleID: pgGoogleID, + }) + if err != nil { + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) && pgErr.Code == "23505" { + return nil, domain.ErrConflict + } + return nil, err + } + return mapDbUserToDomain(dbUser), nil +} + +func (r *pgxUserRepository) Delete( + ctx context.Context, + id uuid.UUID, +) error { + return r.q.DeleteUser(ctx, id) +} diff --git a/backend/internal/service/auth_service.go b/backend/internal/service/auth_service.go new file mode 100644 index 0000000..834d8f1 --- /dev/null +++ b/backend/internal/service/auth_service.go @@ -0,0 +1,258 @@ +package service + +import ( + "context" + "errors" + "fmt" + "log/slog" + "time" + + "github.com/Sosokker/todolist-backend/internal/auth" + "github.com/Sosokker/todolist-backend/internal/config" + "github.com/Sosokker/todolist-backend/internal/domain" + "github.com/Sosokker/todolist-backend/internal/repository" + "github.com/golang-jwt/jwt/v5" + "golang.org/x/crypto/bcrypt" + "golang.org/x/oauth2" +) + +type authService struct { + userRepo repository.UserRepository + cfg *config.Config + googleOAuthProv auth.OAuthProvider + logger *slog.Logger +} + +func NewAuthService(repo repository.UserRepository, cfg *config.Config) AuthService { + logger := slog.Default().With("service", "auth") + googleProvider := auth.NewGoogleOAuthProvider(cfg) + return &authService{ + userRepo: repo, + cfg: cfg, + googleOAuthProv: googleProvider, + logger: logger, + } +} + +func (s *authService) Signup(ctx context.Context, creds SignupCredentials) (*domain.User, error) { + if err := ValidateSignupInput(creds); err != nil { + return nil, err + } + + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(creds.Password), bcrypt.DefaultCost) + if err != nil { + slog.ErrorContext(ctx, "Failed to hash password", "error", err) + return nil, domain.ErrInternalServer + } + + newUser := &domain.User{ + Username: creds.Username, + Email: creds.Email, + PasswordHash: string(hashedPassword), + EmailVerified: false, + } + + createdUser, err := s.userRepo.Create(ctx, newUser) + if err != nil { + if errors.Is(err, domain.ErrConflict) { + _, emailErr := s.userRepo.GetByEmail(ctx, creds.Email) + if emailErr == nil { + return nil, fmt.Errorf("email already exists: %w", domain.ErrConflict) + } + return nil, fmt.Errorf("username already exists: %w", domain.ErrConflict) + } + slog.ErrorContext(ctx, "Failed to create user in db", "error", err) + return nil, domain.ErrInternalServer + } + + return createdUser, nil +} + +func (s *authService) Login(ctx context.Context, creds LoginCredentials) (string, *domain.User, error) { + if err := ValidateLoginInput(creds); err != nil { + return "", nil, err + } + + user, err := s.userRepo.GetByEmail(ctx, creds.Email) + if err != nil { + if errors.Is(err, domain.ErrNotFound) { + return "", nil, fmt.Errorf("invalid email or password: %w", domain.ErrUnauthorized) + } + slog.ErrorContext(ctx, "Failed to get user by email", "error", err) + return "", nil, domain.ErrInternalServer + } + + if user.PasswordHash == "" && user.GoogleID != nil { + return "", nil, fmt.Errorf("please log in using Google: %w", domain.ErrUnauthorized) + } + if user.PasswordHash == "" { + slog.ErrorContext(ctx, "User found with empty password hash", "userId", user.ID) + return "", nil, fmt.Errorf("account error, please contact support: %w", domain.ErrInternalServer) + } + + err = bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(creds.Password)) + if err != nil { + if errors.Is(err, bcrypt.ErrMismatchedHashAndPassword) { + return "", nil, fmt.Errorf("invalid email or password: %w", domain.ErrUnauthorized) + } + slog.ErrorContext(ctx, "Error comparing password hash", "error", err, "userId", user.ID) + return "", nil, domain.ErrInternalServer + } + + token, err := s.GenerateJWT(user) + if err != nil { + return "", nil, err + } + + return token, user, nil +} + +func (s *authService) GenerateJWT(user *domain.User) (string, error) { + expirationTime := time.Now().Add(time.Duration(s.cfg.JWT.ExpiryMinutes) * time.Minute) + claims := &auth.Claims{ + UserID: user.ID, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(expirationTime), + IssuedAt: jwt.NewNumericDate(time.Now()), + Subject: user.ID.String(), + }, + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + tokenString, err := token.SignedString([]byte(s.cfg.JWT.Secret)) + if err != nil { + slog.Error("Failed to sign JWT token", "error", err, "userId", user.ID) + return "", domain.ErrInternalServer + } + return tokenString, nil +} + +func (s *authService) ValidateJWT(tokenString string) (*domain.User, error) { + claims := &auth.Claims{} + token, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) { + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) + } + return []byte(s.cfg.JWT.Secret), nil + }) + + if err != nil { + if errors.Is(err, jwt.ErrTokenExpired) { + return nil, fmt.Errorf("token has expired: %w", domain.ErrUnauthorized) + } + if errors.Is(err, jwt.ErrTokenMalformed) { + return nil, fmt.Errorf("token is malformed: %w", domain.ErrUnauthorized) + } + slog.Warn("JWT validation failed", "error", err) + return nil, fmt.Errorf("invalid token: %w", domain.ErrUnauthorized) + } + + if !token.Valid { + return nil, fmt.Errorf("invalid token: %w", domain.ErrUnauthorized) + } + + user, err := s.userRepo.GetByID(context.Background(), claims.UserID) + if err != nil { + if errors.Is(err, domain.ErrNotFound) { + return nil, fmt.Errorf("user associated with token not found: %w", domain.ErrUnauthorized) + } + slog.Error("Failed to fetch user for valid JWT", "error", err, "userId", claims.UserID) + return nil, domain.ErrInternalServer + } + + return user, nil +} + +func (s *authService) GetGoogleAuthConfig() *oauth2.Config { + return s.googleOAuthProv.GetOAuth2Config() +} + +type GoogleUserInfo struct { + ID string `json:"id"` + Email string `json:"email"` + VerifiedEmail bool `json:"verified_email"` + Name string `json:"name"` +} + +func (s *authService) HandleGoogleCallback(ctx context.Context, code string) (string, *domain.User, error) { + token, err := s.googleOAuthProv.ExchangeCode(ctx, code) + if err != nil { + s.logger.ErrorContext(ctx, "Failed to exchange google auth code via provider", "error", err) + return "", nil, fmt.Errorf("google auth exchange failed: %w", domain.ErrUnauthorized) + } + + userInfo, err := s.googleOAuthProv.FetchUserInfo(ctx, token) + if err != nil { + s.logger.ErrorContext(ctx, "Failed to fetch google user info via provider", "error", err) + return "", nil, fmt.Errorf("failed to get user info from google: %w", domain.ErrUnauthorized) + } + + if !userInfo.VerifiedEmail { + return "", nil, fmt.Errorf("google email not verified: %w", domain.ErrUnauthorized) + } + + user, err := s.userRepo.GetByGoogleID(ctx, userInfo.ID) + if err != nil && !errors.Is(err, domain.ErrNotFound) { + slog.ErrorContext(ctx, "Failed to check user by google ID", "error", err, "googleId", userInfo.ID) + return "", nil, domain.ErrInternalServer + } + + if user != nil { + jwtToken, jwtErr := s.GenerateJWT(user) + if jwtErr != nil { + return "", nil, jwtErr + } + return jwtToken, user, nil + } + + user, err = s.userRepo.GetByEmail(ctx, userInfo.Email) + if err != nil && !errors.Is(err, domain.ErrNotFound) { + slog.ErrorContext(ctx, "Failed to check user by email during google callback", "error", err, "email", userInfo.Email) + return "", nil, domain.ErrInternalServer + } + + if user != nil { + if user.GoogleID != nil && *user.GoogleID != userInfo.ID { + slog.WarnContext(ctx, "User email associated with different Google ID", "userId", user.ID, "existingGoogleId", *user.GoogleID, "newGoogleId", userInfo.ID) + return "", nil, fmt.Errorf("email already linked to a different Google account: %w", domain.ErrConflict) + } + if user.GoogleID == nil { + updateData := &domain.User{GoogleID: &userInfo.ID, EmailVerified: true} + updatedUser, updateErr := s.userRepo.Update(ctx, user.ID, updateData) + if updateErr != nil { + slog.ErrorContext(ctx, "Failed to link Google ID to existing user", "error", updateErr, "userId", user.ID) + return "", nil, domain.ErrInternalServer + } + user = updatedUser + } + + jwtToken, jwtErr := s.GenerateJWT(user) + if jwtErr != nil { + return "", nil, jwtErr + } + return jwtToken, user, nil + } + + newUser := &domain.User{ + Username: userInfo.Name, + Email: userInfo.Email, + PasswordHash: "", + EmailVerified: true, + GoogleID: &userInfo.ID, + } + + createdUser, err := s.userRepo.Create(ctx, newUser) + if err != nil { + if errors.Is(err, domain.ErrConflict) { + return "", nil, fmt.Errorf("failed to create user, potential conflict: %w", domain.ErrConflict) + } + slog.ErrorContext(ctx, "Failed to create new user from google info", "error", err) + return "", nil, domain.ErrInternalServer + } + + jwtToken, jwtErr := s.GenerateJWT(createdUser) + if jwtErr != nil { + return "", nil, jwtErr + } + return jwtToken, createdUser, nil +} diff --git a/backend/internal/service/gcs_storage.go b/backend/internal/service/gcs_storage.go new file mode 100644 index 0000000..d419f33 --- /dev/null +++ b/backend/internal/service/gcs_storage.go @@ -0,0 +1,94 @@ +package service + +import ( + "context" + "fmt" + "io" + "log/slog" + "mime" + "path/filepath" + "strings" + + "cloud.google.com/go/storage" + "github.com/Sosokker/todolist-backend/internal/config" + "github.com/google/uuid" + "google.golang.org/api/option" +) + +type gcsStorageService struct { + bucket string + client *storage.Client + logger *slog.Logger + baseDir string +} + +func NewGCStorageService(cfg config.GCSStorageConfig, logger *slog.Logger) (FileStorageService, error) { + opts := []option.ClientOption{} + if cfg.CredentialsFile != "" { + opts = append(opts, option.WithCredentialsFile(cfg.CredentialsFile)) + } + client, err := storage.NewClient(context.Background(), opts...) + if err != nil { + return nil, fmt.Errorf("failed to create GCS client: %w", err) + } + return &gcsStorageService{ + bucket: cfg.BucketName, + client: client, + logger: logger.With("service", "gcsstorage"), + baseDir: cfg.BaseDir, + }, nil +} + +func (s *gcsStorageService) GenerateUniqueObjectName(originalFilename string) string { + ext := filepath.Ext(originalFilename) + return uuid.NewString() + ext +} + +func (s *gcsStorageService) Upload(ctx context.Context, userID, todoID uuid.UUID, originalFilename string, reader io.Reader, size int64) (string, string, error) { + objectName := filepath.Join(s.baseDir, userID.String(), todoID.String(), s.GenerateUniqueObjectName(originalFilename)) + wc := s.client.Bucket(s.bucket).Object(objectName).NewWriter(ctx) + wc.ContentType = mime.TypeByExtension(filepath.Ext(originalFilename)) + wc.ChunkSize = 0 + written, err := io.Copy(wc, reader) + if err != nil { + wc.Close() + s.logger.ErrorContext(ctx, "Failed to upload to GCS", "error", err, "object", objectName) + return "", "", fmt.Errorf("failed to upload to GCS: %w", err) + } + if written != size { + wc.Close() + s.logger.WarnContext(ctx, "File size mismatch during GCS upload", "expected", size, "written", written, "object", objectName) + return "", "", fmt.Errorf("file size mismatch during upload") + } + if err := wc.Close(); err != nil { + s.logger.ErrorContext(ctx, "Failed to finalize GCS upload", "error", err, "object", objectName) + return "", "", fmt.Errorf("failed to finalize upload: %w", err) + } + contentType := wc.ContentType + if contentType == "" { + contentType = "application/octet-stream" + } + return objectName, contentType, nil +} + +func (s *gcsStorageService) Delete(ctx context.Context, storageID string) error { + objectName := filepath.Clean(storageID) + if strings.Contains(objectName, "..") { + s.logger.WarnContext(ctx, "Attempted directory traversal in GCS delete", "storageId", storageID) + return fmt.Errorf("invalid storage ID") + } + o := s.client.Bucket(s.bucket).Object(objectName) + err := o.Delete(ctx) + if err != nil && err != storage.ErrObjectNotExist { + s.logger.ErrorContext(ctx, "Failed to delete GCS object", "error", err, "storageId", storageID) + return fmt.Errorf("could not delete GCS object: %w", err) + } + s.logger.InfoContext(ctx, "GCS object deleted", "storageId", storageID) + return nil +} + +func (s *gcsStorageService) GetURL(ctx context.Context, storageID string) (string, error) { + objectName := filepath.Clean(storageID) + url := fmt.Sprintf("https://storage.googleapis.com/%s/%s", s.bucket, objectName) + return url, nil +} diff --git a/backend/internal/service/interfaces.go b/backend/internal/service/interfaces.go new file mode 100644 index 0000000..bbb77d9 --- /dev/null +++ b/backend/internal/service/interfaces.go @@ -0,0 +1,147 @@ +package service + +import ( + "context" + "io" + "time" + + "github.com/Sosokker/todolist-backend/internal/domain" + "github.com/google/uuid" + "golang.org/x/oauth2" +) + +// --- Auth Service --- +type SignupCredentials struct { + Username string + Email string + Password string +} + +type LoginCredentials struct { + Email string + Password string +} + +type AuthService interface { + Signup(ctx context.Context, creds SignupCredentials) (*domain.User, error) + Login(ctx context.Context, creds LoginCredentials) (token string, user *domain.User, err error) + GenerateJWT(user *domain.User) (string, error) + ValidateJWT(tokenString string) (*domain.User, error) + GetGoogleAuthConfig() *oauth2.Config + HandleGoogleCallback(ctx context.Context, code string) (token string, user *domain.User, err error) +} + +// --- User Service --- +type UpdateUserInput struct { + Username *string +} + +type UserService interface { + GetUserByID(ctx context.Context, id uuid.UUID) (*domain.User, error) + UpdateUser(ctx context.Context, userID uuid.UUID, input UpdateUserInput) (*domain.User, error) +} + +// --- Tag Service --- +type CreateTagInput struct { + Name string + Color *string + Icon *string +} + +type UpdateTagInput struct { + Name *string + Color *string + Icon *string +} + +type TagService interface { + CreateTag(ctx context.Context, userID uuid.UUID, input CreateTagInput) (*domain.Tag, error) + GetTagByID(ctx context.Context, tagID, userID uuid.UUID) (*domain.Tag, error) + ListUserTags(ctx context.Context, userID uuid.UUID) ([]domain.Tag, error) + UpdateTag(ctx context.Context, tagID, userID uuid.UUID, input UpdateTagInput) (*domain.Tag, error) + DeleteTag(ctx context.Context, tagID, userID uuid.UUID) error + ValidateUserTags(ctx context.Context, userID uuid.UUID, tagIDs []uuid.UUID) error +} + +// --- Todo Service --- +type CreateTodoInput struct { + Title string + Description *string + Status *domain.TodoStatus + Deadline *time.Time + TagIDs []uuid.UUID +} + +type UpdateTodoInput struct { + Title *string + Description *string + Status *domain.TodoStatus + Deadline *time.Time + TagIDs *[]uuid.UUID + Attachments *[]string +} + +type ListTodosInput struct { + Status *domain.TodoStatus + TagID *uuid.UUID + DeadlineBefore *time.Time + DeadlineAfter *time.Time + Limit int + Offset int +} + +type TodoService interface { + CreateTodo(ctx context.Context, userID uuid.UUID, input CreateTodoInput) (*domain.Todo, error) + GetTodoByID(ctx context.Context, todoID, userID uuid.UUID) (*domain.Todo, error) // Includes tags, subtasks + ListUserTodos(ctx context.Context, userID uuid.UUID, input ListTodosInput) ([]domain.Todo, error) // Includes tags + UpdateTodo(ctx context.Context, todoID, userID uuid.UUID, input UpdateTodoInput) (*domain.Todo, error) + DeleteTodo(ctx context.Context, todoID, userID uuid.UUID) error + // Subtask methods delegate to SubtaskService but check Todo ownership first + ListSubtasks(ctx context.Context, todoID, userID uuid.UUID) ([]domain.Subtask, error) + CreateSubtask(ctx context.Context, todoID, userID uuid.UUID, input CreateSubtaskInput) (*domain.Subtask, error) + UpdateSubtask(ctx context.Context, todoID, subtaskID, userID uuid.UUID, input UpdateSubtaskInput) (*domain.Subtask, error) + DeleteSubtask(ctx context.Context, todoID, subtaskID, userID uuid.UUID) error + // Attachment methods + AddAttachment(ctx context.Context, todoID, userID uuid.UUID, fileName string, fileSize int64, fileContent io.Reader) (*domain.AttachmentInfo, error) // Returns info like ID/URL + DeleteAttachment(ctx context.Context, todoID, userID uuid.UUID, attachmentID string) error +} + +// --- Subtask Service --- +type CreateSubtaskInput struct { + Description string +} + +type UpdateSubtaskInput struct { + Description *string + Completed *bool +} + +// SubtaskService operates assuming the parent Todo's ownership has already been verified +type SubtaskService interface { + Create(ctx context.Context, todoID uuid.UUID, input CreateSubtaskInput) (*domain.Subtask, error) + GetByID(ctx context.Context, subtaskID, userID uuid.UUID) (*domain.Subtask, error) // Still need userID for underlying repo call + ListByTodo(ctx context.Context, todoID, userID uuid.UUID) ([]domain.Subtask, error) // Still need userID for underlying repo call + Update(ctx context.Context, subtaskID, userID uuid.UUID, input UpdateSubtaskInput) (*domain.Subtask, error) // Still need userID + Delete(ctx context.Context, subtaskID, userID uuid.UUID) error // Still need userID +} + +// FileStorageService defines the interface for handling file uploads and deletions. +type FileStorageService interface { + // Upload saves the content from the reader and returns a unique storage identifier (e.g., path/key) and the content type. + Upload(ctx context.Context, userID, todoID uuid.UUID, originalFilename string, reader io.Reader, size int64) (storageID string, contentType string, err error) + // Delete removes the file associated with the given storage identifier. + Delete(ctx context.Context, storageID string) error + // GetURL retrieves a publicly accessible URL for the storage ID (optional, might not be needed if files are served differently). + GetURL(ctx context.Context, storageID string) (string, error) + GenerateUniqueObjectName(originalFilename string) string +} + +// ServiceRegistry bundles services +type ServiceRegistry struct { + Auth AuthService + User UserService + Tag TagService + Todo TodoService + Subtask SubtaskService + Storage FileStorageService +} diff --git a/backend/internal/service/local_storage.go b/backend/internal/service/local_storage.go new file mode 100644 index 0000000..838c265 --- /dev/null +++ b/backend/internal/service/local_storage.go @@ -0,0 +1,185 @@ +package service + +import ( + "context" + "fmt" + "io" + "log/slog" + "mime" + "net/http" + "os" + "path/filepath" + "strings" + + "github.com/Sosokker/todolist-backend/internal/config" + "github.com/google/uuid" +) + +type localStorageService struct { + basePath string + logger *slog.Logger +} + +// NewLocalStorageService creates a service for storing files on the local disk. +func NewLocalStorageService(cfg config.LocalStorageConfig, logger *slog.Logger) (FileStorageService, error) { + if cfg.Path == "" { + return nil, fmt.Errorf("local storage path cannot be empty") + } + + // Ensure the base directory exists + err := os.MkdirAll(cfg.Path, 0750) // Use appropriate permissions + if err != nil { + return nil, fmt.Errorf("failed to create local storage directory '%s': %w", cfg.Path, err) + } + + logger.Info("Local file storage initialized", "path", cfg.Path) + return &localStorageService{ + basePath: cfg.Path, + logger: logger.With("service", "localstorage"), + }, nil +} + +// GenerateUniqueObjectName creates a unique path/filename for storage. +// Example: user_uuid/todo_uuid/file_uuid.ext +func (s *localStorageService) GenerateUniqueObjectName(originalFilename string) string { + ext := filepath.Ext(originalFilename) + fileName := uuid.NewString() + ext + return fileName +} + +func (s *localStorageService) Upload(ctx context.Context, userID, todoID uuid.UUID, originalFilename string, reader io.Reader, size int64) (string, string, error) { + // Create a unique filename + uniqueFilename := s.GenerateUniqueObjectName(originalFilename) + + // Create user/todo specific subdirectory structure + subDir := filepath.Join(userID.String(), todoID.String()) + fullDir := filepath.Join(s.basePath, subDir) + if err := os.MkdirAll(fullDir, 0750); err != nil { + s.logger.ErrorContext(ctx, "Failed to create subdirectory for upload", "error", err, "path", fullDir) + return "", "", fmt.Errorf("could not create storage directory: %w", err) + } + + // Define the full path for the file + filePath := filepath.Join(fullDir, uniqueFilename) + storageID := filepath.Join(subDir, uniqueFilename) // Relative path used as ID + + // Create the destination file + dst, err := os.Create(filePath) + if err != nil { + s.logger.ErrorContext(ctx, "Failed to create destination file", "error", err, "path", filePath) + return "", "", fmt.Errorf("could not create file: %w", err) + } + defer dst.Close() + + // Copy the content from the reader to the destination file + written, err := io.Copy(dst, reader) + if err != nil { + // Attempt to clean up partially written file + os.Remove(filePath) + s.logger.ErrorContext(ctx, "Failed to copy file content", "error", err, "path", filePath) + return "", "", fmt.Errorf("could not write file content: %w", err) + } + if written != size { + // Attempt to clean up file if size mismatch (could indicate truncated upload) + os.Remove(filePath) + s.logger.WarnContext(ctx, "File size mismatch during upload", "expected", size, "written", written, "path", filePath) + return "", "", fmt.Errorf("file size mismatch during upload") + } + + // Detect content type + contentType := s.detectContentType(filePath, originalFilename) + + s.logger.InfoContext(ctx, "File uploaded successfully", "storageId", storageID, "originalName", originalFilename, "size", size, "contentType", contentType) + // Return the relative path as the storage identifier + return storageID, contentType, nil +} + +func (s *localStorageService) Delete(ctx context.Context, storageID string) error { + // Prevent directory traversal attacks + cleanStorageID := filepath.Clean(storageID) + if strings.Contains(cleanStorageID, "..") { + s.logger.WarnContext(ctx, "Attempted directory traversal in delete", "storageId", storageID) + return fmt.Errorf("invalid storage ID") + } + + fullPath := filepath.Join(s.basePath, cleanStorageID) + + err := os.Remove(fullPath) + if err != nil { + if os.IsNotExist(err) { + s.logger.WarnContext(ctx, "Attempted to delete non-existent file", "storageId", storageID) + // Consider returning nil here if deleting non-existent is okay + return nil + } + s.logger.ErrorContext(ctx, "Failed to delete file", "error", err, "storageId", storageID) + return fmt.Errorf("could not delete file: %w", err) + } + + s.logger.InfoContext(ctx, "File deleted successfully", "storageId", storageID) + + dir := filepath.Dir(fullPath) + if isEmpty, _ := IsDirEmpty(dir); isEmpty { + os.Remove(dir) + } + dir = filepath.Dir(dir) // Go up one more level + if isEmpty, _ := IsDirEmpty(dir); isEmpty { + os.Remove(dir) + } + + return nil +} + +// GetURL for local storage might just return a path or require a separate file server. +// This implementation returns a placeholder indicating it's not a direct URL. +func (s *localStorageService) GetURL(ctx context.Context, storageID string) (string, error) { + // Local storage doesn't inherently provide a web URL. + // You would typically need a separate static file server pointing to `basePath`. + // For now, return the storageID itself or a placeholder path. + // Example: If you have a file server at /static/uploads mapped to basePath: + // return "/static/uploads/" + filepath.ToSlash(storageID), nil + return fmt.Sprintf("local://%s", storageID), nil // Placeholder indicating local storage +} + +// detectContentType tries to determine the MIME type of the file. +func (s *localStorageService) detectContentType(filePath string, originalFilename string) string { + // First, try based on file extension + ext := filepath.Ext(originalFilename) + mimeType := mime.TypeByExtension(ext) + if mimeType != "" { + return mimeType + } + + // If extension didn't work, try reading the first 512 bytes + file, err := os.Open(filePath) + if err != nil { + s.logger.Warn("Could not open file for content type detection", "error", err, "path", filePath) + return "application/octet-stream" // Default fallback + } + defer file.Close() + + buffer := make([]byte, 512) + n, err := file.Read(buffer) + if err != nil && err != io.EOF { + s.logger.Warn("Could not read file for content type detection", "error", err, "path", filePath) + return "application/octet-stream" + } + + // http.DetectContentType works best with the file beginning + mimeType = http.DetectContentType(buffer[:n]) + return mimeType +} + +func IsDirEmpty(name string) (bool, error) { + f, err := os.Open(name) + if err != nil { + return false, err + } + defer f.Close() + + // Read just one entry. If EOF, directory is empty. + _, err = f.Readdirnames(1) + if err == io.EOF { + return true, nil + } + return false, err // Either not empty or error during read +} diff --git a/backend/internal/service/subtask_service.go b/backend/internal/service/subtask_service.go new file mode 100644 index 0000000..bbed163 --- /dev/null +++ b/backend/internal/service/subtask_service.go @@ -0,0 +1,140 @@ +package service + +import ( + "context" + "errors" + "log/slog" + + "github.com/Sosokker/todolist-backend/internal/domain" // Adjust path + "github.com/Sosokker/todolist-backend/internal/repository" // Adjust path + "github.com/google/uuid" +) + +type subtaskService struct { + subtaskRepo repository.SubtaskRepository + logger *slog.Logger +} + +// NewSubtaskService creates a new SubtaskService +func NewSubtaskService(repo repository.SubtaskRepository /*, todoRepo repository.TodoRepository */) SubtaskService { + return &subtaskService{ + subtaskRepo: repo, + logger: slog.Default().With("service", "subtask"), + } +} + +func (s *subtaskService) Create(ctx context.Context, todoID uuid.UUID, input CreateSubtaskInput) (*domain.Subtask, error) { + if err := ValidateCreateSubtaskInput(input); err != nil { + return nil, err + } + // Ownership check of parent todo (todoID) should be done *before* calling this method, + // typically in the TodoService which orchestrates subtask operations. + // Alternatively, the repository methods should enforce this via joins (as done in the example repo). + + subtask := &domain.Subtask{ + TodoID: todoID, + Description: input.Description, + Completed: false, // Default on create + } + + createdSubtask, err := s.subtaskRepo.Create(ctx, subtask) + if err != nil { + // Repo handles foreign key violation check returning ErrBadRequest + if errors.Is(err, domain.ErrBadRequest) { + s.logger.WarnContext(ctx, "Subtask creation failed, invalid parent todo", "todoId", todoID) + return nil, err + } + s.logger.ErrorContext(ctx, "Failed to create subtask in repo", "error", err, "todoId", todoID) + return nil, domain.ErrInternalServer + } + + s.logger.InfoContext(ctx, "Subtask created successfully", "subtaskId", createdSubtask.ID, "todoId", todoID) + return createdSubtask, nil +} + +func (s *subtaskService) GetByID(ctx context.Context, subtaskID, userID uuid.UUID) (*domain.Subtask, error) { + subtask, err := s.subtaskRepo.GetByID(ctx, subtaskID, userID) + if err != nil { + if errors.Is(err, domain.ErrNotFound) { + s.logger.WarnContext(ctx, "Subtask not found or access denied", "subtaskId", subtaskID, "userId", userID) + } else { + s.logger.ErrorContext(ctx, "Failed to get subtask by ID from repo", "error", err, "subtaskId", subtaskID, "userId", userID) + err = domain.ErrInternalServer + } + return nil, err + } + return subtask, nil +} + +func (s *subtaskService) ListByTodo(ctx context.Context, todoID, userID uuid.UUID) ([]domain.Subtask, error) { + subtasks, err := s.subtaskRepo.ListByTodo(ctx, todoID, userID) + if err != nil { + s.logger.ErrorContext(ctx, "Failed to list subtasks by todo from repo", "error", err, "todoId", todoID, "userId", userID) + return nil, domain.ErrInternalServer + } + s.logger.DebugContext(ctx, "Listed subtasks for todo", "todoId", todoID, "userId", userID, "count", len(subtasks)) + return subtasks, nil +} + +func (s *subtaskService) Update(ctx context.Context, subtaskID, userID uuid.UUID, input UpdateSubtaskInput) (*domain.Subtask, error) { + if err := ValidateUpdateSubtaskInput(input); err != nil { + return nil, err + } + // Get existing first to ensure NotFound/Forbidden is returned correctly before attempting update, + // and to have the existing data if only partial fields are provided in input. + existingSubtask, err := s.GetByID(ctx, subtaskID, userID) + if err != nil { + return nil, err // Handles NotFound/Forbidden/Internal + } + + updateData := &domain.Subtask{ + Description: existingSubtask.Description, + Completed: existingSubtask.Completed, + } + needsUpdate := false + + if input.Description != nil { + updateData.Description = *input.Description + needsUpdate = true + } + if input.Completed != nil { + updateData.Completed = *input.Completed + needsUpdate = true + } + + if !needsUpdate { + s.logger.InfoContext(ctx, "No fields provided for subtask update", "subtaskId", subtaskID, "userId", userID) + return existingSubtask, nil + } + + updatedSubtask, err := s.subtaskRepo.Update(ctx, subtaskID, userID, updateData) + if err != nil { + if errors.Is(err, domain.ErrNotFound) { + s.logger.WarnContext(ctx, "Subtask update failed, not found or access denied", "subtaskId", subtaskID, "userId", userID) + } else { + s.logger.ErrorContext(ctx, "Failed to update subtask in repo", "error", err, "subtaskId", subtaskID, "userId", userID) + err = domain.ErrInternalServer + } + return nil, err + } + + s.logger.InfoContext(ctx, "Subtask updated successfully", "subtaskId", subtaskID, "userId", userID) + return updatedSubtask, nil +} + +func (s *subtaskService) Delete(ctx context.Context, subtaskID, userID uuid.UUID) error { + // Check existence and ownership first to return proper NotFound/Forbidden. + _, err := s.GetByID(ctx, subtaskID, userID) + if err != nil { + return err // Handles NotFound/Forbidden/Internal + } + + err = s.subtaskRepo.Delete(ctx, subtaskID, userID) + if err != nil { + s.logger.ErrorContext(ctx, "Failed to delete subtask from repo", "error", err, "subtaskId", subtaskID, "userId", userID) + return domain.ErrInternalServer + } + + s.logger.InfoContext(ctx, "Subtask deleted successfully", "subtaskId", subtaskID, "userId", userID) + return nil +} diff --git a/backend/internal/service/tag_service.go b/backend/internal/service/tag_service.go new file mode 100644 index 0000000..33d2e84 --- /dev/null +++ b/backend/internal/service/tag_service.go @@ -0,0 +1,235 @@ +package service + +import ( + "context" + "errors" + "fmt" + "log/slog" + "strings" + + "github.com/Sosokker/todolist-backend/internal/cache" // Adjust path + "github.com/Sosokker/todolist-backend/internal/domain" // Adjust path + "github.com/Sosokker/todolist-backend/internal/repository" // Adjust path + "github.com/google/uuid" +) + +type tagService struct { + tagRepo repository.TagRepository + cache cache.Cache // Inject Cache interface + logger *slog.Logger +} + +// NewTagService creates a new TagService +func NewTagService(repo repository.TagRepository /*, cache cache.Cache */) TagService { + logger := slog.Default().With("service", "tag") + + var c cache.Cache = nil // Or initialize cache here: cache.NewMemoryCache(...) + if c != nil { + logger.Info("TagService initialized with caching enabled") + } else { + logger.Info("TagService initialized without caching") + } + + return &tagService{ + tagRepo: repo, + cache: c, + logger: logger, + } +} + +func (s *tagService) getCacheKey(tagID uuid.UUID) string { + // Consider user-specific keys if caching user-scoped data: fmt.Sprintf("user:%s:tag:%s", userID, tagID) + return "tag:" + tagID.String() +} + +func (s *tagService) CreateTag(ctx context.Context, userID uuid.UUID, input CreateTagInput) (*domain.Tag, error) { + // Use centralized validation + if err := ValidateCreateTagInput(input); err != nil { + return nil, err + } + + tag := &domain.Tag{ + UserID: userID, + Name: input.Name, + Color: input.Color, + Icon: input.Icon, + } + + createdTag, err := s.tagRepo.Create(ctx, tag) + if err != nil { + if errors.Is(err, domain.ErrConflict) { + s.logger.WarnContext(ctx, "Tag creation conflict", "userId", userID, "tagName", input.Name) + return nil, err + } + s.logger.ErrorContext(ctx, "Failed to create tag in repo", "error", err, "userId", userID) + return nil, domain.ErrInternalServer + } + + s.logger.InfoContext(ctx, "Tag created successfully", "tagId", createdTag.ID, "userId", userID) + return createdTag, nil +} + +func (s *tagService) GetTagByID(ctx context.Context, tagID, userID uuid.UUID) (*domain.Tag, error) { + cacheKey := s.getCacheKey(tagID) + + if s.cache != nil { + if cachedTag, found := s.cache.Get(ctx, cacheKey); found { + if tag, ok := cachedTag.(*domain.Tag); ok { + // IMPORTANT: Verify ownership even on cache hit + if tag.UserID != userID { + s.logger.WarnContext(ctx, "Cache hit for tag owned by different user", "tagId", tagID, "ownerId", tag.UserID, "requesterId", userID) + return nil, domain.ErrNotFound + } + s.logger.DebugContext(ctx, "GetTagByID cache hit", "tagId", tagID, "userId", userID) + return tag, nil + } else { + s.logger.WarnContext(ctx, "Invalid type found in tag cache", "key", cacheKey) + s.cache.Delete(ctx, cacheKey) + } + } else { + s.logger.DebugContext(ctx, "GetTagByID cache miss", "tagId", tagID, "userId", userID) + } + } + + tag, err := s.tagRepo.GetByID(ctx, tagID, userID) + if err != nil { + if errors.Is(err, domain.ErrNotFound) { + s.logger.WarnContext(ctx, "Tag not found by ID in repo", "tagId", tagID, "userId", userID) + } else { + s.logger.ErrorContext(ctx, "Failed to get tag by ID from repo", "error", err, "tagId", tagID, "userId", userID) + err = domain.ErrInternalServer + } + return nil, err + } + + if s.cache != nil { + s.cache.Set(ctx, cacheKey, tag, 0) + s.logger.DebugContext(ctx, "Set tag in cache", "tagId", tagID) + } + + return tag, nil +} + +func (s *tagService) ListUserTags(ctx context.Context, userID uuid.UUID) ([]domain.Tag, error) { + s.logger.DebugContext(ctx, "Listing user tags", "userId", userID) + + tags, err := s.tagRepo.ListByUser(ctx, userID) + if err != nil { + s.logger.ErrorContext(ctx, "Failed to list user tags from repo", "error", err, "userId", userID) + return nil, domain.ErrInternalServer + } + + s.logger.DebugContext(ctx, "Found user tags", "userId", userID, "count", len(tags)) + return tags, nil +} + +func (s *tagService) UpdateTag(ctx context.Context, tagID, userID uuid.UUID, input UpdateTagInput) (*domain.Tag, error) { + // Get existing tag first to ensure it exists and belongs to user + existingTag, err := s.GetTagByID(ctx, tagID, userID) + if err != nil { + return nil, err + } + + if err := ValidateUpdateTagInput(input); err != nil { + return nil, err + } + + updateData := &domain.Tag{ + Name: existingTag.Name, + Color: existingTag.Color, + Icon: existingTag.Icon, + } + needsUpdate := false + + if input.Name != nil { + updateData.Name = *input.Name + needsUpdate = true + } + if input.Color != nil { + updateData.Color = input.Color + needsUpdate = true + } + if input.Icon != nil { + updateData.Icon = input.Icon + needsUpdate = true + } + + if !needsUpdate { + s.logger.InfoContext(ctx, "No fields provided for tag update", "tagId", tagID, "userId", userID) + return existingTag, nil + } + + updatedTag, err := s.tagRepo.Update(ctx, tagID, userID, updateData) + if err != nil { + if errors.Is(err, domain.ErrConflict) { + s.logger.WarnContext(ctx, "Tag update conflict", "error", err, "tagId", tagID, "userId", userID, "conflictingName", updateData.Name) + return nil, err + } + s.logger.ErrorContext(ctx, "Failed to update tag in repo", "error", err, "tagId", tagID, "userId", userID) + return nil, domain.ErrInternalServer + } + + if s.cache != nil { + cacheKey := s.getCacheKey(tagID) + s.cache.Delete(ctx, cacheKey) + s.logger.DebugContext(ctx, "Invalidated tag cache after update", "tagId", tagID) + } + + s.logger.InfoContext(ctx, "Tag updated successfully", "tagId", tagID, "userId", userID) + return updatedTag, nil +} + +func (s *tagService) DeleteTag(ctx context.Context, tagID, userID uuid.UUID) error { + _, err := s.GetTagByID(ctx, tagID, userID) + if err != nil { + return err + } + + err = s.tagRepo.Delete(ctx, tagID, userID) + if err != nil { + s.logger.ErrorContext(ctx, "Failed to delete tag from repo", "error", err, "tagId", tagID, "userId", userID) + return domain.ErrInternalServer + } + + if s.cache != nil { + cacheKey := s.getCacheKey(tagID) + s.cache.Delete(ctx, cacheKey) + s.logger.DebugContext(ctx, "Invalidated tag cache after delete", "tagId", tagID) + } + + s.logger.InfoContext(ctx, "Tag deleted successfully", "tagId", tagID, "userId", userID) + return nil +} + +// ValidateUserTags checks if all provided tag IDs exist and belong to the user. +func (s *tagService) ValidateUserTags(ctx context.Context, userID uuid.UUID, tagIDs []uuid.UUID) error { + if len(tagIDs) == 0 { + return nil + } + + // GetByIDs repo method already filters by userID + foundTags, err := s.tagRepo.GetByIDs(ctx, tagIDs, userID) + if err != nil { + s.logger.ErrorContext(ctx, "Failed to get tags by IDs during validation", "error", err, "userId", userID) + return domain.ErrInternalServer + } + + if len(foundTags) != len(tagIDs) { + foundMap := make(map[uuid.UUID]bool) + for _, t := range foundTags { + foundMap[t.ID] = true + } + missing := []string{} + for _, reqID := range tagIDs { + if !foundMap[reqID] { + missing = append(missing, reqID.String()) + } + } + errMsg := "invalid or forbidden tag IDs: " + strings.Join(missing, ", ") + s.logger.WarnContext(ctx, "Tag validation failed", "userId", userID, "missingTags", missing) + return fmt.Errorf("%s: %w", errMsg, domain.ErrBadRequest) + } + + s.logger.DebugContext(ctx, "User tags validated successfully", "userId", userID, "tagCount", len(tagIDs)) + return nil +} diff --git a/backend/internal/service/todo_service.go b/backend/internal/service/todo_service.go new file mode 100644 index 0000000..7f548f2 --- /dev/null +++ b/backend/internal/service/todo_service.go @@ -0,0 +1,355 @@ +package service + +import ( + "context" + "errors" + "fmt" + "io" + "log/slog" + + "github.com/Sosokker/todolist-backend/internal/domain" + "github.com/Sosokker/todolist-backend/internal/repository" + "github.com/google/uuid" +) + +type todoService struct { + todoRepo repository.TodoRepository + tagService TagService // Depend on TagService for validation + subtaskService SubtaskService // Depend on SubtaskService + storageService FileStorageService + logger *slog.Logger +} + +// NewTodoService creates a new TodoService +func NewTodoService( + todoRepo repository.TodoRepository, + tagService TagService, + subtaskService SubtaskService, + storageService FileStorageService, +) TodoService { + return &todoService{ + todoRepo: todoRepo, + tagService: tagService, + subtaskService: subtaskService, + storageService: storageService, + logger: slog.Default().With("service", "todo"), + } +} + +func (s *todoService) CreateTodo(ctx context.Context, userID uuid.UUID, input CreateTodoInput) (*domain.Todo, error) { + // Validate input + if input.Title == "" { + return nil, fmt.Errorf("title is required: %w", domain.ErrValidation) + } + + // Validate associated Tag IDs belong to the user + if len(input.TagIDs) > 0 { + if err := s.tagService.ValidateUserTags(ctx, userID, input.TagIDs); err != nil { + return nil, err // Propagate validation or not found errors + } + } + + // Set default status if not provided + status := domain.StatusPending + if input.Status != nil { + status = *input.Status + } + + newTodo := &domain.Todo{ + UserID: userID, + Title: input.Title, + Description: input.Description, + Status: status, + Deadline: input.Deadline, + TagIDs: input.TagIDs, + Attachments: []string{}, + } + + createdTodo, err := s.todoRepo.Create(ctx, newTodo) + if err != nil { + s.logger.ErrorContext(ctx, "Failed to create todo in repo", "error", err, "userId", userID) + return nil, domain.ErrInternalServer + } + + // Associate Tags if provided (after Todo creation) + if len(input.TagIDs) > 0 { + if err = s.todoRepo.SetTags(ctx, createdTodo.ID, input.TagIDs); err != nil { + s.logger.ErrorContext(ctx, "Failed to associate tags during todo creation", "error", err, "todoId", createdTodo.ID) + _ = s.todoRepo.Delete(ctx, createdTodo.ID, userID) // Best effort cleanup + return nil, domain.ErrInternalServer + } + createdTodo.TagIDs = input.TagIDs + } + + return createdTodo, nil +} + +func (s *todoService) GetTodoByID(ctx context.Context, todoID, userID uuid.UUID) (*domain.Todo, error) { + todo, err := s.todoRepo.GetByID(ctx, todoID, userID) + if err != nil { + if errors.Is(err, domain.ErrNotFound) { + s.logger.WarnContext(ctx, "Todo not found or forbidden", "todoId", todoID, "userId", userID) + return nil, domain.ErrNotFound + } + s.logger.ErrorContext(ctx, "Failed to get todo from repo", "error", err, "todoId", todoID, "userId", userID) + return nil, domain.ErrInternalServer + } + + // Eager load associated Tags and Subtasks + tags, err := s.todoRepo.GetTags(ctx, todoID) + if err != nil { + s.logger.WarnContext(ctx, "Failed to get tags for todo", "error", err, "todoId", todoID) + } else { + todo.Tags = tags + todo.TagIDs = make([]uuid.UUID, 0, len(tags)) + for _, tag := range tags { + todo.TagIDs = append(todo.TagIDs, tag.ID) + } + } + + subtasks, err := s.subtaskService.ListByTodo(ctx, todoID, userID) + if err != nil { + s.logger.WarnContext(ctx, "Failed to get subtasks for todo", "error", err, "todoId", todoID) + } else { + todo.Subtasks = subtasks + } + + return todo, nil +} + +func (s *todoService) ListUserTodos(ctx context.Context, userID uuid.UUID, input ListTodosInput) ([]domain.Todo, error) { + if input.Limit <= 0 { + input.Limit = 20 + } + if input.Offset < 0 { + input.Offset = 0 + } + + repoParams := repository.ListTodosParams{ + UserID: userID, + Status: input.Status, + TagID: input.TagID, + DeadlineBefore: input.DeadlineBefore, + DeadlineAfter: input.DeadlineAfter, + ListParams: repository.ListParams{ + Limit: input.Limit, + Offset: input.Offset, + }, + } + + todos, err := s.todoRepo.ListByUser(ctx, repoParams) + if err != nil { + s.logger.ErrorContext(ctx, "Failed to list todos from repo", "error", err, "userId", userID) + return nil, domain.ErrInternalServer + } + + // Optional: Eager load Tags for each Todo in the list efficiently + // 1. Collect all Todo IDs + // 2. Make one batch query to get all tags for these todos (e.g., WHERE todo_id IN (...)) + // 3. Map tags back to their respective todos + // See todo_repo.go for implementation notes. + // This avoids N+1 queries. + + return todos, nil +} + +func (s *todoService) UpdateTodo(ctx context.Context, todoID, userID uuid.UUID, input UpdateTodoInput) (*domain.Todo, error) { + existingTodo, err := s.todoRepo.GetByID(ctx, todoID, userID) + if err != nil { + return nil, err + } + + updateData := &domain.Todo{ + ID: existingTodo.ID, + UserID: existingTodo.UserID, + Title: existingTodo.Title, + Description: existingTodo.Description, + Status: existingTodo.Status, + Deadline: existingTodo.Deadline, + Attachments: existingTodo.Attachments, + } + + updated := false + + if input.Title != nil { + if *input.Title == "" { + return nil, fmt.Errorf("title cannot be empty: %w", domain.ErrValidation) + } + updateData.Title = *input.Title + updated = true + } + if input.Description != nil { + updateData.Description = input.Description + updated = true + } + if input.Status != nil { + updateData.Status = *input.Status + updated = true + } + if input.Deadline != nil { + updateData.Deadline = input.Deadline + updated = true + } + + tagsUpdated := false + if input.TagIDs != nil { + if len(*input.TagIDs) > 0 { + if err := s.tagService.ValidateUserTags(ctx, userID, *input.TagIDs); err != nil { + return nil, err + } + } + err = s.todoRepo.SetTags(ctx, todoID, *input.TagIDs) + if err != nil { + s.logger.ErrorContext(ctx, "Failed to update tags for todo", "error", err, "todoId", todoID) + return nil, domain.ErrInternalServer + } + updateData.TagIDs = *input.TagIDs + tagsUpdated = true + } + + attachmentsUpdated := false + if input.Attachments != nil { + err = s.todoRepo.SetAttachments(ctx, todoID, userID, *input.Attachments) + if err != nil { + s.logger.ErrorContext(ctx, "Failed to update attachments list for todo", "error", err, "todoId", todoID) + return nil, domain.ErrInternalServer + } + updateData.Attachments = *input.Attachments + attachmentsUpdated = true + } + + var updatedRepoTodo *domain.Todo + if updated { + updatedRepoTodo, err = s.todoRepo.Update(ctx, todoID, userID, updateData) + if err != nil { + s.logger.ErrorContext(ctx, "Failed to update todo in repo", "error", err, "todoId", todoID) + return nil, domain.ErrInternalServer + } + } else { + updatedRepoTodo = updateData + } + + if !updated && (tagsUpdated || attachmentsUpdated) { + updatedRepoTodo.Title = existingTodo.Title + updatedRepoTodo.Description = existingTodo.Description + } + + finalTodo, err := s.GetTodoByID(ctx, todoID, userID) + if err != nil { + s.logger.WarnContext(ctx, "Failed to reload todo after update, returning partial data", "error", err, "todoId", todoID) + return updatedRepoTodo, nil + } + + return finalTodo, nil +} + +func (s *todoService) DeleteTodo(ctx context.Context, todoID, userID uuid.UUID) error { + existingTodo, err := s.todoRepo.GetByID(ctx, todoID, userID) + if err != nil { + return err + } + + attachmentIDsToDelete := existingTodo.Attachments + + err = s.todoRepo.Delete(ctx, todoID, userID) + if err != nil { + s.logger.ErrorContext(ctx, "Failed to delete todo from repo", "error", err, "todoId", todoID, "userId", userID) + return domain.ErrInternalServer + } + + for _, storageID := range attachmentIDsToDelete { + if err := s.storageService.Delete(ctx, storageID); err != nil { + s.logger.WarnContext(ctx, "Failed to delete attachment file during todo deletion", "error", err, "storageId", storageID, "todoId", todoID) + } + } + return nil +} + +// --- Subtask Delegation Methods --- + +func (s *todoService) ListSubtasks(ctx context.Context, todoID, userID uuid.UUID) ([]domain.Subtask, error) { + _, err := s.todoRepo.GetByID(ctx, todoID, userID) + if err != nil { + return nil, err + } + return s.subtaskService.ListByTodo(ctx, todoID, userID) +} + +func (s *todoService) CreateSubtask(ctx context.Context, todoID, userID uuid.UUID, input CreateSubtaskInput) (*domain.Subtask, error) { + _, err := s.todoRepo.GetByID(ctx, todoID, userID) + if err != nil { + return nil, err + } + return s.subtaskService.Create(ctx, todoID, input) +} + +func (s *todoService) UpdateSubtask(ctx context.Context, todoID, subtaskID, userID uuid.UUID, input UpdateSubtaskInput) (*domain.Subtask, error) { + return s.subtaskService.Update(ctx, subtaskID, userID, input) +} + +func (s *todoService) DeleteSubtask(ctx context.Context, todoID, subtaskID, userID uuid.UUID) error { + return s.subtaskService.Delete(ctx, subtaskID, userID) +} + +// --- Attachment Methods --- (Implementation depends on FileStorageService) + +func (s *todoService) AddAttachment(ctx context.Context, todoID, userID uuid.UUID, originalFilename string, fileSize int64, fileContent io.Reader) (*domain.AttachmentInfo, error) { + _, err := s.todoRepo.GetByID(ctx, todoID, userID) + if err != nil { + return nil, err + } + + storageID, contentType, err := s.storageService.Upload(ctx, userID, todoID, originalFilename, fileContent, fileSize) + if err != nil { + s.logger.ErrorContext(ctx, "Failed to upload attachment to storage", "error", err, "todoId", todoID, "fileName", originalFilename) + return nil, domain.ErrInternalServer + } + + if err = s.todoRepo.AddAttachment(ctx, todoID, userID, storageID); err != nil { + s.logger.ErrorContext(ctx, "Failed to add attachment storage ID to todo", "error", err, "todoId", todoID, "storageId", storageID) + if delErr := s.storageService.Delete(context.Background(), storageID); delErr != nil { + s.logger.ErrorContext(ctx, "Failed to delete orphaned attachment file after DB error", "deleteError", delErr, "storageId", storageID) + } + return nil, domain.ErrInternalServer + } + + fileURL, _ := s.storageService.GetURL(ctx, storageID) + + return &domain.AttachmentInfo{ + FileID: storageID, + FileName: originalFilename, + FileURL: fileURL, + ContentType: contentType, + Size: fileSize, + }, nil +} + +func (s *todoService) DeleteAttachment(ctx context.Context, todoID, userID uuid.UUID, storageID string) error { + todo, err := s.todoRepo.GetByID(ctx, todoID, userID) + if err != nil { + return err + } + + found := false + for _, att := range todo.Attachments { + if att == storageID { + found = true + break + } + } + if !found { + return fmt.Errorf("attachment '%s' not found on todo %s: %w", storageID, todoID, domain.ErrNotFound) + } + + if err = s.todoRepo.RemoveAttachment(ctx, todoID, userID, storageID); err != nil { + s.logger.ErrorContext(ctx, "Failed to remove attachment ID from todo", "error", err, "todoId", todoID, "storageId", storageID) + return domain.ErrInternalServer + } + + if err = s.storageService.Delete(ctx, storageID); err != nil { + s.logger.ErrorContext(ctx, "Failed to delete attachment file from storage after removing DB ref", "error", err, "storageId", storageID) + return nil + } + + return nil +} diff --git a/backend/internal/service/user_service.go b/backend/internal/service/user_service.go new file mode 100644 index 0000000..a94be6e --- /dev/null +++ b/backend/internal/service/user_service.go @@ -0,0 +1,83 @@ +package service + +import ( + "context" + "errors" + "fmt" + "log/slog" + + "github.com/Sosokker/todolist-backend/internal/domain" // Adjust path + "github.com/Sosokker/todolist-backend/internal/repository" // Adjust path + "github.com/google/uuid" +) + +type userService struct { + userRepo repository.UserRepository + logger *slog.Logger +} + +func NewUserService(repo repository.UserRepository) UserService { + return &userService{ + userRepo: repo, + logger: slog.Default().With("service", "user"), + } +} + +func (s *userService) GetUserByID(ctx context.Context, id uuid.UUID) (*domain.User, error) { + user, err := s.userRepo.GetByID(ctx, id) + if err != nil { + if errors.Is(err, domain.ErrNotFound) { + s.logger.WarnContext(ctx, "User not found by ID", "userId", id) + return nil, domain.ErrNotFound + } + s.logger.ErrorContext(ctx, "Failed to get user by ID from repo", "error", err, "userId", id) + return nil, domain.ErrInternalServer + } + return user, nil +} + +func (s *userService) UpdateUser(ctx context.Context, userID uuid.UUID, input UpdateUserInput) (*domain.User, error) { + // GetUserByID handles NotFound/Forbidden error + existingUser, err := s.GetUserByID(ctx, userID) + if err != nil { + return nil, err + } + + // Prepare update data DTO for the repository + updateData := &domain.User{ + // Copy non-updatable fields or handle defaults + } + needsUpdate := false + + if input.Username != nil { + if err := ValidateUsername(*input.Username); err != nil { + return nil, err + } + updateData.Username = *input.Username + needsUpdate = true + } else { + updateData.Username = existingUser.Username + } + + // TODO: Add logic for other updatable fields (e.g., email) + // Password updates should involve hashing and likely be a separate endpoint/service method. + // Email updates might require a verification flow. + + if !needsUpdate { + s.logger.InfoContext(ctx, "No fields provided for user update", "userId", userID) + return existingUser, nil + } + + updatedUser, err := s.userRepo.Update(ctx, userID, updateData) + if err != nil { + if errors.Is(err, domain.ErrConflict) { + s.logger.WarnContext(ctx, "User update conflict", "error", err, "userId", userID, "conflictingUsername", updateData.Username) + return nil, fmt.Errorf("username '%s' is already taken: %w", updateData.Username, domain.ErrConflict) + } + s.logger.ErrorContext(ctx, "Failed to update user in repo", "error", err, "userId", userID) + return nil, domain.ErrInternalServer + } + + s.logger.InfoContext(ctx, "User updated successfully", "userId", userID) + return updatedUser, nil +} diff --git a/backend/internal/service/validation.go b/backend/internal/service/validation.go new file mode 100644 index 0000000..7f69728 --- /dev/null +++ b/backend/internal/service/validation.go @@ -0,0 +1,192 @@ +package service + +import ( + "fmt" + "net/mail" + "regexp" + "strings" + + "github.com/Sosokker/todolist-backend/internal/domain" +) + +const ( + MinUsernameLength = 3 + MaxUsernameLength = 50 + MinPasswordLength = 6 + MinTagNameLength = 1 + MaxTagNameLength = 50 + MaxTagIconLength = 30 + MinTodoTitleLength = 1 + MinSubtaskDescLength = 1 +) + +// Regex for simple hex color validation (#RRGGBB) +var hexColorRegex = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`) + +// ValidateUsername checks username constraints. +func ValidateUsername(username string) error { + if len(username) < MinUsernameLength || len(username) > MaxUsernameLength { + return fmt.Errorf("username must be between %d and %d characters: %w", MinUsernameLength, MaxUsernameLength, domain.ErrValidation) + } + // Add other constraints like allowed characters if needed + return nil +} + +// ValidateEmail checks if the email format is valid. +func ValidateEmail(email string) error { + if _, err := mail.ParseAddress(email); err != nil { + return fmt.Errorf("invalid email format: %w", domain.ErrValidation) + } + return nil +} + +// ValidatePassword checks password length. +func ValidatePassword(password string) error { + if len(password) < MinPasswordLength { + return fmt.Errorf("password must be at least %d characters: %w", MinPasswordLength, domain.ErrValidation) + } + return nil +} + +// ValidateSignupInput validates the input for user registration. +func ValidateSignupInput(creds SignupCredentials) error { + if err := ValidateUsername(creds.Username); err != nil { + return err + } + if err := ValidateEmail(creds.Email); err != nil { + return err + } + if err := ValidatePassword(creds.Password); err != nil { + return err + } + return nil +} + +// ValidateLoginInput validates the input for user login. +func ValidateLoginInput(creds LoginCredentials) error { + if err := ValidateEmail(creds.Email); err != nil { + return err + } + if creds.Password == "" { // Password presence check + return fmt.Errorf("password is required: %w", domain.ErrValidation) + } + return nil +} + +// IsValidHexColor checks if a string is a valid #RRGGBB hex color. +func IsValidHexColor(color string) bool { + return hexColorRegex.MatchString(color) +} + +// ValidateTagName checks tag name constraints. +func ValidateTagName(name string) error { + trimmed := strings.TrimSpace(name) + if len(trimmed) < MinTagNameLength || len(trimmed) > MaxTagNameLength { + return fmt.Errorf("tag name must be between %d and %d characters: %w", MinTagNameLength, MaxTagNameLength, domain.ErrValidation) + } + return nil +} + +// ValidateTagIcon checks tag icon constraints. +func ValidateTagIcon(icon *string) error { + if icon != nil && len(*icon) > MaxTagIconLength { + return fmt.Errorf("tag icon cannot be longer than %d characters: %w", MaxTagIconLength, domain.ErrValidation) + } + return nil +} + +// ValidateCreateTagInput validates input for creating a tag. +func ValidateCreateTagInput(input CreateTagInput) error { + if err := ValidateTagName(input.Name); err != nil { + return err + } + if input.Color != nil && !IsValidHexColor(*input.Color) { + return fmt.Errorf("invalid color format (must be #RRGGBB): %w", domain.ErrValidation) + } + if err := ValidateTagIcon(input.Icon); err != nil { + return err + } + return nil +} + +// ValidateUpdateTagInput validates input for updating a tag. +func ValidateUpdateTagInput(input UpdateTagInput) error { + if input.Name != nil { + if err := ValidateTagName(*input.Name); err != nil { + return err + } + } + if input.Color != nil && !IsValidHexColor(*input.Color) { + return fmt.Errorf("invalid color format (must be #RRGGBB): %w", domain.ErrValidation) + } + if err := ValidateTagIcon(input.Icon); err != nil { // Check pointer directly + return err + } + return nil +} + +// ValidateTodoTitle checks title constraints. +func ValidateTodoTitle(title string) error { + if len(strings.TrimSpace(title)) < MinTodoTitleLength { + return fmt.Errorf("todo title cannot be empty: %w", domain.ErrValidation) + } + return nil +} + +// ValidateCreateTodoInput validates input for creating a todo. +func ValidateCreateTodoInput(input CreateTodoInput) error { + if err := ValidateTodoTitle(input.Title); err != nil { + return err + } + // Optional: Validate Status enum value if needed + // Optional: Validate Deadline is not in the past? + return nil +} + +// ValidateUpdateTodoInput validates input for updating a todo. +func ValidateUpdateTodoInput(input UpdateTodoInput) error { + if input.Title != nil { + if err := ValidateTodoTitle(*input.Title); err != nil { + return err + } + } + // Optional: Validate Status enum value if needed + // Optional: Validate Deadline is not in the past? + return nil +} + +// ValidateSubtaskDescription checks description constraints. +func ValidateSubtaskDescription(desc string) error { + if len(strings.TrimSpace(desc)) < MinSubtaskDescLength { + return fmt.Errorf("subtask description cannot be empty: %w", domain.ErrValidation) + } + return nil +} + +// ValidateCreateSubtaskInput validates input for creating a subtask. +func ValidateCreateSubtaskInput(input CreateSubtaskInput) error { + return ValidateSubtaskDescription(input.Description) +} + +// ValidateUpdateSubtaskInput validates input for updating a subtask. +func ValidateUpdateSubtaskInput(input UpdateSubtaskInput) error { + if input.Description != nil { + if err := ValidateSubtaskDescription(*input.Description); err != nil { + return err + } + } + return nil +} + +// ValidateListParams checks basic pagination parameters. +func ValidateListParams(limit, offset int) error { + if limit < 0 { + return fmt.Errorf("limit cannot be negative: %w", domain.ErrValidation) + } + if offset < 0 { + return fmt.Errorf("offset cannot be negative: %w", domain.ErrValidation) + } + // Add max limit check if desired + // if limit > MaxListLimit { ... } + return nil +} diff --git a/backend/migrations/000001_init_schema.down.sql b/backend/migrations/000001_init_schema.down.sql new file mode 100644 index 0000000..eb478bb --- /dev/null +++ b/backend/migrations/000001_init_schema.down.sql @@ -0,0 +1,23 @@ +-- Drop triggers first +DROP TRIGGER IF EXISTS set_timestamp_users ON users; +DROP TRIGGER IF EXISTS set_timestamp_tags ON tags; +DROP TRIGGER IF EXISTS set_timestamp_todos ON todos; +DROP TRIGGER IF EXISTS set_timestamp_subtasks ON subtasks; +DROP TRIGGER IF EXISTS set_timestamp_attachments ON attachments; + +-- Drop the trigger function +DROP FUNCTION IF EXISTS trigger_set_timestamp(); + +-- Drop tables in reverse order of creation (or based on dependencies) +DROP TABLE IF EXISTS attachments; +DROP TABLE IF EXISTS todo_tags; +DROP TABLE IF EXISTS subtasks; +DROP TABLE IF EXISTS todos; +DROP TABLE IF EXISTS tags; +DROP TABLE IF EXISTS users; + +-- Drop custom types +DROP TYPE IF EXISTS todo_status; + +-- Drop extensions (usually not needed in down migration unless specifically required) +DROP EXTENSION IF EXISTS "uuid-ossp"; \ No newline at end of file diff --git a/backend/migrations/000001_init_schema.up.sql b/backend/migrations/000001_init_schema.up.sql new file mode 100644 index 0000000..9ef6899 --- /dev/null +++ b/backend/migrations/000001_init_schema.up.sql @@ -0,0 +1,128 @@ +-- Enable UUID extension +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; + +-- Users Table +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + username VARCHAR(50) UNIQUE NOT NULL, + email VARCHAR(255) UNIQUE NOT NULL, + password_hash VARCHAR(255) NOT NULL, -- Store hashed password + email_verified BOOLEAN NOT NULL DEFAULT FALSE, + google_id VARCHAR(255) UNIQUE NULL, -- For Google OAuth association + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Add index for faster email lookup +CREATE INDEX idx_users_email ON users(email); + +-- Tags Table +CREATE TABLE tags ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name VARCHAR(50) NOT NULL, + color VARCHAR(7) NULL, -- e.g., #FF5733 + icon VARCHAR(30) NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + -- Ensure tag name is unique per user + UNIQUE (user_id, name) +); + +-- Index for faster tag lookup by user +CREATE INDEX idx_tags_user_id ON tags(user_id); + +-- Todos Table +CREATE TYPE todo_status AS ENUM ('pending', 'in-progress', 'completed'); + +CREATE TABLE todos ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + title VARCHAR(255) NOT NULL, + description TEXT NULL, + status todo_status NOT NULL DEFAULT 'pending', + deadline TIMESTAMPTZ NULL, + -- attachments array will store file IDs or identifiers + attachments TEXT[] NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Indexes for common filtering/sorting +CREATE INDEX idx_todos_user_id ON todos(user_id); +CREATE INDEX idx_todos_status ON todos(status); +CREATE INDEX idx_todos_deadline ON todos(deadline); + +-- Subtasks Table +CREATE TABLE subtasks ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + todo_id UUID NOT NULL REFERENCES todos(id) ON DELETE CASCADE, + description TEXT NOT NULL, + completed BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Index for faster subtask lookup by todo +CREATE INDEX idx_subtasks_todo_id ON subtasks(todo_id); + +-- Todo_Tags Junction Table (Many-to-Many) +CREATE TABLE todo_tags ( + todo_id UUID NOT NULL REFERENCES todos(id) ON DELETE CASCADE, + tag_id UUID NOT NULL REFERENCES tags(id) ON DELETE CASCADE, + PRIMARY KEY (todo_id, tag_id) +); + +-- Index for faster lookup when filtering todos by tag +CREATE INDEX idx_todo_tags_tag_id ON todo_tags(tag_id); + +-- Optional: Attachments Metadata Table (if storing more than just IDs/URLs in Todo) +-- Consider if you need detailed tracking, otherwise the TEXT[] on todos might suffice +CREATE TABLE attachments ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + todo_id UUID NOT NULL REFERENCES todos(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, -- Redundant? Maybe for direct permission checks + file_name VARCHAR(255) NOT NULL, + storage_path VARCHAR(512) NOT NULL, -- e.g., S3 key or local path + content_type VARCHAR(100) NOT NULL, + size BIGINT NOT NULL, + uploaded_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX idx_attachments_todo_id ON attachments(todo_id); + + +-- Function to automatically update 'updated_at' timestamp +CREATE OR REPLACE FUNCTION trigger_set_timestamp() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +-- Apply the trigger to tables that have 'updated_at' +CREATE TRIGGER set_timestamp_users +BEFORE UPDATE ON users +FOR EACH ROW +EXECUTE PROCEDURE trigger_set_timestamp(); + +CREATE TRIGGER set_timestamp_tags +BEFORE UPDATE ON tags +FOR EACH ROW +EXECUTE PROCEDURE trigger_set_timestamp(); + +CREATE TRIGGER set_timestamp_todos +BEFORE UPDATE ON todos +FOR EACH ROW +EXECUTE PROCEDURE trigger_set_timestamp(); + +CREATE TRIGGER set_timestamp_subtasks +BEFORE UPDATE ON subtasks +FOR EACH ROW +EXECUTE PROCEDURE trigger_set_timestamp(); + +-- Optional trigger for attachments table if created +CREATE TRIGGER set_timestamp_attachments +BEFORE UPDATE ON attachments +FOR EACH ROW +EXECUTE PROCEDURE trigger_set_timestamp(); \ No newline at end of file diff --git a/backend/openapi.yaml b/backend/openapi.yaml new file mode 100644 index 0000000..037df03 --- /dev/null +++ b/backend/openapi.yaml @@ -0,0 +1,1183 @@ +openapi: 3.0.3 +info: + title: Todolist API + version: 1.2.0 # Incremented version + description: | + API for managing Todo items, including CRUD operations, subtasks, deadlines, attachments, and user-defined Tags. + Supports user authentication via email/password (JWT) and Google OAuth. + Designed for use with oapi-codegen and Chi. + + **Note on Notifications:** Real-time notifications (e.g., via SSE or WebSockets) are planned but not fully described in this OpenAPI specification due to limitations in representing asynchronous APIs. These will be documented separately. + + **Note on Tag Deletion:** Deleting a Tag will typically remove its association from any Todo items currently using it. +servers: + # The base path for all API routes defined below. + # oapi-codegen will use this when setting up routes with HandlerFromMux. + - url: /api/v1 + description: API version 1 + +components: + # Security Schemes used by the API + securitySchemes: + BearerAuth: # Used by API clients (non-browser) + type: http + scheme: bearer + bearerFormat: JWT + description: JWT authentication token provided in the Authorization header. + CookieAuth: # Used by the web application (browser) + type: apiKey + in: cookie + name: jwt_token # Name needs to match config.AppConfig.CookieName + description: JWT authentication token provided via an HTTP-only cookie. + + # Reusable Schemas + schemas: + # --- User Schemas --- + User: + type: object + description: Represents a registered user. + properties: + id: + type: string + format: uuid + readOnly: true + username: + type: string + email: + type: string + format: email + emailVerified: + type: boolean + readOnly: true + description: Indicates if the user's email has been verified (e.g., via OAuth or email confirmation). + createdAt: + type: string + format: date-time + readOnly: true + updatedAt: + type: string + format: date-time + readOnly: true + required: + - id + - username + - email + - emailVerified + - createdAt + - updatedAt + + SignupRequest: + type: object + description: Data required for signing up a new user via email/password. + properties: + username: + type: string + minLength: 3 + maxLength: 50 + email: + type: string + format: email + password: + type: string + minLength: 6 + writeOnly: true # Password should not appear in responses + required: + - username + - email + - password + + LoginRequest: + type: object + description: Data required for logging in via email/password. + properties: + email: + type: string + format: email + password: + type: string + writeOnly: true + required: + - email + - password + + LoginResponse: + type: object + description: Response containing the JWT access token for API clients. For browser clients, a cookie is typically set instead. + properties: + accessToken: + type: string + description: JWT access token. + tokenType: + type: string + default: "Bearer" + description: Type of the token (always Bearer). + required: + - accessToken + - tokenType + + UpdateUserRequest: + type: object + description: Data for updating user details. + properties: + username: + type: string + minLength: 3 + maxLength: 50 + # Add other updatable fields like email if needed (consider verification flow) + # Password updates might warrant a separate endpoint /users/me/password + # No required fields, allows partial updates + + # --- Tag Schemas --- + Tag: + type: object + description: Represents a user-defined tag for organizing Todos. + properties: + id: + type: string + format: uuid + readOnly: true + userId: + type: string + format: uuid + readOnly: true + description: The ID of the user who owns this Tag. + name: + type: string + description: Name of the tag (e.g., "Work", "Personal"). Must be unique per user. + color: + type: string + format: hexcolor # Custom format hint, e.g., #FF5733 + nullable: true + description: Optional color associated with the tag. + icon: + type: string + nullable: true + description: Optional identifier for an icon associated with the tag (e.g., 'briefcase', 'home'). Frontend maps this to actual icon display. + createdAt: + type: string + format: date-time + readOnly: true + updatedAt: + type: string + format: date-time + readOnly: true + required: + - id + - userId + - name + - createdAt + - updatedAt + + CreateTagRequest: + type: object + description: Data required to create a new Tag. + properties: + name: + type: string + minLength: 1 + maxLength: 50 + description: Name of the tag. Must be unique for the user. + color: + type: string + format: hexcolor + nullable: true + description: Optional color code (e.g., #FF5733). + icon: + type: string + nullable: true + maxLength: 30 + description: Optional icon identifier. + required: + - name + + UpdateTagRequest: + type: object + description: Data for updating an existing Tag. All fields are optional. + properties: + name: + type: string + minLength: 1 + maxLength: 50 + description: New name for the tag. Must be unique for the user. + color: + type: string + format: hexcolor + nullable: true + description: New color code. + icon: + type: string + nullable: true + maxLength: 30 + description: New icon identifier. + + # --- Todo Schemas --- + Todo: + type: object + description: Represents a Todo item. + properties: + id: + type: string + format: uuid + readOnly: true + userId: + type: string + format: uuid + readOnly: true + description: The ID of the user who owns this Todo. + title: + type: string + description: The main title or task of the Todo. + description: + type: string + nullable: true + description: Optional detailed description of the Todo. + status: + type: string + enum: [pending, in-progress, completed] + default: pending + description: Current status of the Todo item. + deadline: + type: string + format: date-time + nullable: true + description: Optional deadline for the Todo item. + tagIds: # <-- Added + type: array + items: + type: string + format: uuid + description: List of IDs of Tags associated with this Todo. + default: [] + attachments: + type: array + items: + type: string + description: List of identifiers (e.g., URLs or IDs) for attached files/images. Managed via upload/update endpoints. + default: [] + subtasks: + type: array + items: + $ref: '#/components/schemas/Subtask' + description: List of subtasks associated with this Todo. Usually fetched/managed via subtask endpoints. + readOnly: true # Subtasks typically managed via their own endpoints + createdAt: + type: string + format: date-time + readOnly: true + updatedAt: + type: string + format: date-time + readOnly: true + required: + - id + - userId + - title + - status + - tagIds # <-- Added + - attachments + - createdAt + - updatedAt + + CreateTodoRequest: + type: object + description: Data required to create a new Todo item. + properties: + title: + type: string + minLength: 1 + description: + type: string + nullable: true + status: + type: string + enum: [pending, in-progress, completed] + default: pending + deadline: + type: string + format: date-time + nullable: true + tagIds: # <-- Added + type: array + items: + type: string + format: uuid + description: Optional list of existing Tag IDs to associate with the new Todo. IDs must belong to the user. + default: [] + required: + - title + + UpdateTodoRequest: + type: object + description: Data for updating an existing Todo item. All fields are optional for partial updates. + properties: + title: + type: string + minLength: 1 + description: + type: string + nullable: true + status: + type: string + enum: [pending, in-progress, completed] + deadline: + type: string + format: date-time + nullable: true + tagIds: # <-- Added + type: array + items: + type: string + format: uuid + description: Replace the existing list of associated Tag IDs. IDs must belong to the user. + attachments: # Allow updating the list of attachments explicitly + type: array + items: + type: string + description: Replace the existing list of attachment identifiers. Use upload/delete endpoints for managing actual files. + + # --- Subtask Schemas --- + Subtask: + type: object + description: Represents a subtask associated with a Todo item. + properties: + id: + type: string + format: uuid + readOnly: true + todoId: + type: string + format: uuid + readOnly: true + description: The ID of the parent Todo item. + description: + type: string + description: Description of the subtask. + completed: + type: boolean + default: false + description: Whether the subtask is completed. + createdAt: + type: string + format: date-time + readOnly: true + updatedAt: + type: string + format: date-time + readOnly: true + required: + - id + - todoId + - description + - completed + - createdAt + - updatedAt + + CreateSubtaskRequest: + type: object + description: Data required to create a new Subtask. + properties: + description: + type: string + minLength: 1 + required: + - description + + UpdateSubtaskRequest: + type: object + description: Data for updating an existing Subtask. Both fields are optional. + properties: + description: + type: string + minLength: 1 + completed: + type: boolean + + # --- File Upload Schemas --- + FileUploadResponse: + type: object + description: Response after successfully uploading a file. + properties: + fileId: + type: string + description: Unique identifier for the uploaded file. + fileName: + type: string + description: Original name of the uploaded file. + fileUrl: + type: string + format: url + description: URL to access the uploaded file. + contentType: + type: string + description: MIME type of the uploaded file. + size: + type: integer + format: int64 + description: Size of the uploaded file in bytes. + required: + - fileId + - fileName + - fileUrl + - contentType + - size + + # --- Error Schema --- + Error: + type: object + description: Standard error response format. + properties: + code: + type: integer + format: int32 + description: HTTP status code or application-specific code. + message: + type: string + description: Detailed error message. + required: + - code + - message + + # Reusable Responses + responses: + BadRequest: + description: Invalid input (e.g., validation error, missing fields, invalid tag ID). + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + Unauthorized: + description: Authentication failed (e.g., invalid credentials, invalid/expired token/cookie, missing authentication). + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + Forbidden: + description: Authorization failed (e.g., user does not have permission to access or modify the resource, such as another user's tag). + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + NotFound: + description: The requested resource (e.g., Todo, Tag, Subtask) was not found. + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + Conflict: + description: Conflict (e.g., username or email already exists, tag name already exists for the user, resource state conflict). + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + InternalServerError: + description: Internal server error. + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + +# Security Requirement applied globally or per-operation +# Most endpoints require either Bearer or Cookie auth. +security: + - BearerAuth: [] + - CookieAuth: [] + +# API Path Definitions +paths: + # --- Authentication Endpoints --- + /auth/signup: + post: + summary: Register a new user via email/password (API). + operationId: signupUserApi + tags: [Auth] + security: [] # No auth required to sign up + requestBody: + required: true + description: User details for registration. + content: + application/json: + schema: + $ref: "#/components/schemas/SignupRequest" + responses: + "201": + description: User created successfully. Returns the new user object. + content: + application/json: + schema: + $ref: "#/components/schemas/User" + "400": + $ref: "#/components/responses/BadRequest" + "409": + $ref: "#/components/responses/Conflict" # e.g., Email or Username already exists + "500": + $ref: "#/components/responses/InternalServerError" + + /auth/login: + post: + summary: Log in a user via email/password (API). + description: Authenticates a user and returns a JWT access token in the response body for API clients. For browser clients, this endpoint typically also sets an HTTP-only cookie containing the JWT. + operationId: loginUserApi + tags: [Auth] + security: [] # No auth required to log in + requestBody: + required: true + description: User credentials for login. + content: + application/json: + schema: + $ref: "#/components/schemas/LoginRequest" + responses: + "200": + description: Login successful. Returns JWT token for API clients. Sets auth cookie for browsers. + content: + application/json: + schema: + $ref: "#/components/schemas/LoginResponse" + headers: + Set-Cookie: # Indicate that a cookie might be set for browser clients + schema: + type: string + description: Contains the JWT authentication cookie (e.g., `jwt_token=...; HttpOnly; Secure; Path=/; SameSite=Lax`) + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" # Invalid credentials + "500": + $ref: "#/components/responses/InternalServerError" + + /auth/logout: # Often useful to have an explicit logout + post: + summary: Log out the current user. + description: Invalidates the current session (e.g., clears the authentication cookie). + operationId: logoutUser + tags: [Auth] + # Requires authentication to know *who* is logging out to clear their session/cookie + security: + - BearerAuth: [] + - CookieAuth: [] + responses: + "204": + description: Logout successful. No content returned. + headers: + Set-Cookie: # Indicate that the cookie is being cleared + schema: + type: string + description: Clears the JWT authentication cookie (e.g., `jwt_token=; HttpOnly; Secure; Path=/; Max-Age=0; SameSite=Lax`) + "401": + $ref: "#/components/responses/Unauthorized" # If not logged in initially + "500": + $ref: "#/components/responses/InternalServerError" + + /auth/google/login: + get: + summary: Initiate Google OAuth login flow. + description: Redirects the user's browser to Google's authentication page. Not a typical REST endpoint, part of the web flow. + operationId: initiateGoogleLogin + tags: [Auth] + security: [] # No API auth needed to start the flow + responses: + "302": + description: Redirect to Google's OAuth consent screen. The 'Location' header contains the redirect URL. + headers: + Location: + schema: + type: string + format: url + description: URL to Google's OAuth endpoint. + "500": + description: Server error during redirect URL generation. + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + + /auth/google/callback: + get: + summary: Callback endpoint for Google OAuth flow. + description: Google redirects the user here after authentication. The server exchanges the received code for tokens, finds/creates the user, generates a JWT, sets the auth cookie, and redirects the user (e.g., to the web app dashboard). + operationId: handleGoogleCallback + tags: [Auth] + security: [] # No API auth needed, Google provides auth code via query param + # parameters: + # - name: code + # in: query + # required: true + # schema: + # type: string + # description: Authorization code provided by Google. + # - name: state + # in: query + # required: false # Recommended for security (CSRF protection) + # schema: + # type: string + # description: Opaque value used to maintain state between the request and callback. + responses: + "302": + description: Authentication successful. Redirects the user to the frontend application (e.g., '/dashboard'). Sets auth cookie. + headers: + Location: + schema: + type: string + description: Redirect URL within the application after successful login. + Set-Cookie: + schema: + type: string + description: Contains the JWT authentication cookie. + "401": + description: Authentication failed with Google or failed to process callback. Redirects to a login/error page. + headers: + Location: + schema: + type: string + description: Redirect URL to an error or login page. + "500": + description: Internal server error during callback processing. Redirects to an error page. + headers: + Location: + schema: + type: string + description: Redirect URL to an error page. + + # --- User Endpoints --- + /users/me: + get: + summary: Get current authenticated user's details. + operationId: getCurrentUser + tags: [Users] + security: + - BearerAuth: [] + - CookieAuth: [] + responses: + "200": + description: Current user details. + content: + application/json: + schema: + $ref: "#/components/schemas/User" + "401": + $ref: "#/components/responses/Unauthorized" + "500": + $ref: "#/components/responses/InternalServerError" + patch: + summary: Update current authenticated user's details. + operationId: updateCurrentUser + tags: [Users] + security: + - BearerAuth: [] + - CookieAuth: [] + requestBody: + required: true + description: User details to update. Only fields provided will be updated. + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateUserRequest' + responses: + "200": + description: User updated successfully. Returns the updated user object. + content: + application/json: + schema: + $ref: "#/components/schemas/User" + "400": + $ref: "#/components/responses/BadRequest" # Validation error + "401": + $ref: "#/components/responses/Unauthorized" + "409": + $ref: "#/components/responses/Conflict" # e.g. Username already taken + "500": + $ref: "#/components/responses/InternalServerError" + + # --- Tag Endpoints --- <-- New Section + /tags: + get: + summary: List all tags created by the current user. + operationId: listUserTags + tags: [Tags] + security: + - BearerAuth: [] + - CookieAuth: [] + responses: + "200": + description: A list of the user's tags. + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Tag' + "401": + $ref: "#/components/responses/Unauthorized" + "500": + $ref: "#/components/responses/InternalServerError" + post: + summary: Create a new tag. + operationId: createTag + tags: [Tags] + security: + - BearerAuth: [] + - CookieAuth: [] + requestBody: + required: true + description: Details of the tag to create. + content: + application/json: + schema: + $ref: '#/components/schemas/CreateTagRequest' + responses: + "201": + description: Tag created successfully. Returns the new tag. + content: + application/json: + schema: + $ref: '#/components/schemas/Tag' + "400": + $ref: "#/components/responses/BadRequest" # Validation error + "401": + $ref: "#/components/responses/Unauthorized" + "409": + $ref: "#/components/responses/Conflict" # Tag name already exists for this user + "500": + $ref: "#/components/responses/InternalServerError" + + /tags/{tagId}: + parameters: + - name: tagId + in: path + required: true + schema: + type: string + format: uuid + description: ID of the Tag. + get: + summary: Get a specific tag by ID. + operationId: getTagById + tags: [Tags] + security: + - BearerAuth: [] + - CookieAuth: [] + responses: + "200": + description: The requested Tag details. + content: + application/json: + schema: + $ref: '#/components/schemas/Tag' + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" # User does not own this tag + "404": + $ref: "#/components/responses/NotFound" + "500": + $ref: "#/components/responses/InternalServerError" + patch: + summary: Update a specific tag by ID. + operationId: updateTagById + tags: [Tags] + security: + - BearerAuth: [] + - CookieAuth: [] + requestBody: + required: true + description: Fields of the tag to update. + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateTagRequest' + responses: + "200": + description: Tag updated successfully. Returns the updated tag. + content: + application/json: + schema: + $ref: '#/components/schemas/Tag' + "400": + $ref: "#/components/responses/BadRequest" # Validation error + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" # User does not own this tag + "404": + $ref: "#/components/responses/NotFound" + "409": + $ref: "#/components/responses/Conflict" # New tag name already exists for this user + "500": + $ref: "#/components/responses/InternalServerError" + delete: + summary: Delete a specific tag by ID. + description: Deletes a tag owned by the user. This will typically remove the tag's ID from any Todos currently associated with it. + operationId: deleteTagById + tags: [Tags] + security: + - BearerAuth: [] + - CookieAuth: [] + responses: + "204": + description: Tag deleted successfully. No content. + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" # User does not own this tag + "404": + $ref: "#/components/responses/NotFound" + "500": + $ref: "#/components/responses/InternalServerError" + + + # --- Todo Endpoints --- + /todos: + get: + summary: List Todo items for the current user. + operationId: listTodos + tags: [Todos] + security: + - BearerAuth: [] + - CookieAuth: [] + parameters: + - name: status + in: query + required: false + schema: + type: string + enum: [pending, in-progress, completed] + description: Filter Todos by status. + - name: tagId # <-- Added filter parameter + in: query + required: false + schema: + type: string + format: uuid + description: Filter Todos by a specific Tag ID. + - name: deadline_before + in: query + required: false + schema: + type: string + format: date-time + description: Filter Todos with deadline before this date/time. + - name: deadline_after + in: query + required: false + schema: + type: string + format: date-time + description: Filter Todos with deadline after this date/time. + - name: limit + in: query + required: false + schema: + type: integer + minimum: 1 + default: 20 + description: Maximum number of Todos to return. + - name: offset + in: query + required: false + schema: + type: integer + minimum: 0 + default: 0 + description: Number of Todos to skip for pagination. + responses: + "200": + description: A list of Todo items matching the criteria. + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Todo" + "401": + $ref: "#/components/responses/Unauthorized" + "500": + $ref: "#/components/responses/InternalServerError" + post: + summary: Create a new Todo item. + operationId: createTodo + tags: [Todos] + security: + - BearerAuth: [] + - CookieAuth: [] + requestBody: + required: true + description: Todo item details to create, optionally including Tag IDs. + content: + application/json: + schema: + $ref: "#/components/schemas/CreateTodoRequest" # Now includes tagIds + responses: + "201": + description: Todo item created successfully. Returns the new Todo. + content: + application/json: + schema: + $ref: "#/components/schemas/Todo" + "400": + $ref: "#/components/responses/BadRequest" # e.g., invalid tag ID provided + "401": + $ref: "#/components/responses/Unauthorized" + "500": + $ref: "#/components/responses/InternalServerError" + + /todos/{todoId}: + parameters: # Parameter applicable to all methods for this path + - name: todoId + in: path + required: true + schema: + type: string + format: uuid + description: ID of the Todo item. + get: + summary: Get a specific Todo item by ID. + operationId: getTodoById + tags: [Todos] + security: + - BearerAuth: [] + - CookieAuth: [] + responses: + "200": + description: The requested Todo item. + content: + application/json: + schema: + $ref: "#/components/schemas/Todo" # Now includes tagIds + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" # User doesn't own this Todo + "404": + $ref: "#/components/responses/NotFound" + "500": + $ref: "#/components/responses/InternalServerError" + patch: + summary: Update a specific Todo item by ID. + operationId: updateTodoById + tags: [Todos] + security: + - BearerAuth: [] + - CookieAuth: [] + requestBody: + required: true + description: Fields of the Todo item to update, potentially including the list of Tag IDs. + content: + application/json: + schema: + $ref: "#/components/schemas/UpdateTodoRequest" # Now includes tagIds + responses: + "200": + description: Todo item updated successfully. Returns the updated Todo. + content: + application/json: + schema: + $ref: "#/components/schemas/Todo" + "400": + $ref: "#/components/responses/BadRequest" # e.g., invalid tag ID provided + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" # User doesn't own this Todo + "404": + $ref: "#/components/responses/NotFound" + "500": + $ref: "#/components/responses/InternalServerError" + delete: + summary: Delete a specific Todo item by ID. + operationId: deleteTodoById + tags: [Todos] + security: + - BearerAuth: [] + - CookieAuth: [] + responses: + "204": + description: Todo item deleted successfully. No content. + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" # User doesn't own this Todo + "404": + $ref: "#/components/responses/NotFound" + "500": + $ref: "#/components/responses/InternalServerError" + + # --- Attachment Endpoints --- + /todos/{todoId}/attachments: + parameters: + - name: todoId + in: path + required: true + schema: + type: string + format: uuid + description: ID of the Todo item to attach the file to. + post: + summary: Upload a file and attach it to a Todo item. + operationId: uploadTodoAttachment + tags: [Attachments, Todos] + security: + - BearerAuth: [] + - CookieAuth: [] + requestBody: + required: true + description: The file to upload. + content: + multipart/form-data: + schema: + type: object + properties: + file: # Name of the form field for the file + type: string + format: binary + required: + - file + # You might add examples or encoding details here if needed + responses: + "201": + description: File uploaded and attached successfully. Returns file details. The Todo's `attachments` array is updated server-side. + content: + application/json: + schema: + $ref: '#/components/schemas/FileUploadResponse' + "400": + $ref: "#/components/responses/BadRequest" # e.g., No file, size limit exceeded, invalid file type + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" # User doesn't own this Todo + "404": + $ref: "#/components/responses/NotFound" # Todo not found + "500": + $ref: "#/components/responses/InternalServerError" # File storage error, etc. + + # --- Subtask Endpoints --- + /todos/{todoId}/subtasks: + parameters: + - name: todoId + in: path + required: true + schema: + type: string + format: uuid + description: ID of the parent Todo item. + get: + summary: List all subtasks for a specific Todo item. + operationId: listSubtasksForTodo + tags: [Subtasks, Todos] + security: + - BearerAuth: [] + - CookieAuth: [] + responses: + "200": + description: A list of subtasks for the specified Todo. + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Subtask' + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" # User doesn't own parent Todo + "404": + $ref: "#/components/responses/NotFound" # Parent Todo not found + "500": + $ref: "#/components/responses/InternalServerError" + post: + summary: Create a new subtask for a specific Todo item. + operationId: createSubtaskForTodo + tags: [Subtasks, Todos] + security: + - BearerAuth: [] + - CookieAuth: [] + requestBody: + required: true + description: Details of the subtask to create. + content: + application/json: + schema: + $ref: '#/components/schemas/CreateSubtaskRequest' + responses: + "201": + description: Subtask created successfully. Returns the new subtask. + content: + application/json: + schema: + $ref: '#/components/schemas/Subtask' + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" # User doesn't own parent Todo + "404": + $ref: "#/components/responses/NotFound" # Parent Todo not found + "500": + $ref: "#/components/responses/InternalServerError" + + /todos/{todoId}/subtasks/{subtaskId}: + parameters: + - name: todoId + in: path + required: true + schema: + type: string + format: uuid + description: ID of the parent Todo item. + - name: subtaskId + in: path + required: true + schema: + type: string + format: uuid + description: ID of the Subtask item. + patch: + summary: Update a specific subtask. + operationId: updateSubtaskById + tags: [Subtasks, Todos] + security: + - BearerAuth: [] + - CookieAuth: [] + requestBody: + required: true + description: Fields of the subtask to update. + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateSubtaskRequest' + responses: + "200": + description: Subtask updated successfully. Returns the updated subtask. + content: + application/json: + schema: + $ref: '#/components/schemas/Subtask' + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" # User doesn't own parent Todo + "404": + $ref: "#/components/responses/NotFound" # Todo or Subtask not found + "500": + $ref: "#/components/responses/InternalServerError" + delete: + summary: Delete a specific subtask. + operationId: deleteSubtaskById + tags: [Subtasks, Todos] + security: + - BearerAuth: [] + - CookieAuth: [] + responses: + "204": + description: Subtask deleted successfully. No content. + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" # User doesn't own parent Todo + "404": + $ref: "#/components/responses/NotFound" # Todo or Subtask not found + "500": + $ref: "#/components/responses/InternalServerError" \ No newline at end of file diff --git a/backend/scripts/generate.sh b/backend/scripts/generate.sh new file mode 100644 index 0000000..e69de29 diff --git a/backend/scripts/migrate.sh b/backend/scripts/migrate.sh new file mode 100644 index 0000000..e69de29 diff --git a/backend/sqlc.yaml b/backend/sqlc.yaml new file mode 100644 index 0000000..66e7d8a --- /dev/null +++ b/backend/sqlc.yaml @@ -0,0 +1,40 @@ +version: "2" +sql: + - engine: "postgresql" + queries: "internal/repository/sqlc/queries/" + schema: "migrations/" # Path to your latest schema or combined migrations + gen: + go: + package: "generated" + sql_package: "pgx/v5" + out: "internal/repository/sqlc/generated" + # Emit interfaces for easier mocking (optional but good practice) + emit_interface: true + # Use pgx/v5 types + emit_exact_table_names: false + emit_json_tags: true + json_tags_case_style: "camel" + # Map Postgres types to Go types, including nulls + overrides: + - db_type: "uuid" + go_type: "github.com/google/uuid.UUID" + nullable: false + + - db_type: "timestamptz" + go_type: "time.Time" + nullable: false + + - db_type: "text" + nullable: true + go_type: "database/sql.NullString" + + - db_type: "timestamptz" + go_type: + import: "time" + type: "Time" + - db_type: "timestamptz" + go_type: + import: "time" + type: "Time" + pointer: true + nullable: true \ No newline at end of file diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..5ef6a52 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,41 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..e215bc4 --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,36 @@ +This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). + +## Getting Started + +First, run the development server: + +```bash +npm run dev +# or +yarn dev +# or +pnpm dev +# or +bun dev +``` + +Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. + +You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. + +This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. + +## Learn More + +To learn more about Next.js, take a look at the following resources: + +- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. +- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. + +You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! + +## Deploy on Vercel + +The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. + +Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. diff --git a/frontend/app/env.d.ts b/frontend/app/env.d.ts new file mode 100644 index 0000000..d0f347d --- /dev/null +++ b/frontend/app/env.d.ts @@ -0,0 +1,5 @@ +namespace NodeJS { + interface ProcessEnv { + NEXT_PUBLIC_API_BASE_URL: string + } +} diff --git a/frontend/app/error.tsx b/frontend/app/error.tsx new file mode 100644 index 0000000..3a9f991 --- /dev/null +++ b/frontend/app/error.tsx @@ -0,0 +1,46 @@ +"use client" + +import { useEffect } from "react" +import Link from "next/link" +import { Button } from "@/components/ui/button" +import { Icons } from "@/components/icons" + +export default function Error({ + error, + reset, +}: { + error: Error & { digest?: string } + reset: () => void +}) { + useEffect(() => { + // Log the error to an error reporting service + console.error(error) + }, [error]) + + return ( +
+
+
+ +
+

Something went wrong!

+

+ We're sorry, but we encountered an unexpected error. Our team has been notified and is working to fix the + issue. +

+
+ + +
+
+
+ ) +} diff --git a/frontend/app/favicon.ico b/frontend/app/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..718d6fea4835ec2d246af9800eddb7ffb276240c GIT binary patch literal 25931 zcmeHv30#a{`}aL_*G&7qml|y<+KVaDM2m#dVr!KsA!#An?kSQM(q<_dDNCpjEux83 zLb9Z^XxbDl(w>%i@8hT6>)&Gu{h#Oeyszu?xtw#Zb1mO{pgX9699l+Qppw7jXaYf~-84xW z)w4x8?=youko|}Vr~(D$UXIbiXABHh`p1?nn8Po~fxRJv}|0e(BPs|G`(TT%kKVJAdg5*Z|x0leQq0 zkdUBvb#>9F()jo|T~kx@OM8$9wzs~t2l;K=woNssA3l6|sx2r3+kdfVW@e^8e*E}v zA1y5{bRi+3Z`uD3{F7LgFJDdvm;nJilkzDku>BwXH(8ItVCXk*-lSJnR?-2UN%hJ){&rlvg`CDTj z)Bzo!3v7Ou#83zEDEFcKt(f1E0~=rqeEbTnMvWR#{+9pg%7G8y>u1OVRUSoox-ovF z2Ydma(;=YuBY(eI|04{hXzZD6_f(v~H;C~y5=DhAC{MMS>2fm~1H_t2$56pc$NH8( z5bH|<)71dV-_oCHIrzrT`2s-5w_+2CM0$95I6X8p^r!gHp+j_gd;9O<1~CEQQGS8) zS9Qh3#p&JM-G8rHekNmKVewU;pJRcTAog68KYo^dRo}(M>36U4Us zfgYWSiHZL3;lpWT=zNAW>Dh#mB!_@Lg%$ms8N-;aPqMn+C2HqZgz&9~Eu z4|Kp<`$q)Uw1R?y(~S>ePdonHxpV1#eSP1B;Ogo+-Pk}6#0GsZZ5!||ev2MGdh}_m z{DeR7?0-1^zVs&`AV6Vt;r3`I`OI_wgs*w=eO%_#7Kepl{B@xiyCANc(l zzIyd4y|c6PXWq9-|KM8(zIk8LPk(>a)zyFWjhT!$HJ$qX1vo@d25W<fvZQ2zUz5WRc(UnFMKHwe1| zWmlB1qdbiA(C0jmnV<}GfbKtmcu^2*P^O?MBLZKt|As~ge8&AAO~2K@zbXelK|4T<{|y4`raF{=72kC2Kn(L4YyenWgrPiv z@^mr$t{#X5VuIMeL!7Ab6_kG$&#&5p*Z{+?5U|TZ`B!7llpVmp@skYz&n^8QfPJzL z0G6K_OJM9x+Wu2gfN45phANGt{7=C>i34CV{Xqlx(fWpeAoj^N0Biu`w+MVcCUyU* zDZuzO0>4Z6fbu^T_arWW5n!E45vX8N=bxTVeFoep_G#VmNlQzAI_KTIc{6>c+04vr zx@W}zE5JNSU>!THJ{J=cqjz+4{L4A{Ob9$ZJ*S1?Ggg3klFp!+Y1@K+pK1DqI|_gq z5ZDXVpge8-cs!o|;K73#YXZ3AShj50wBvuq3NTOZ`M&qtjj#GOFfgExjg8Gn8>Vq5 z`85n+9|!iLCZF5$HJ$Iu($dm?8~-ofu}tEc+-pyke=3!im#6pk_Wo8IA|fJwD&~~F zc16osQ)EBo58U7XDuMexaPRjU@h8tXe%S{fA0NH3vGJFhuyyO!Uyl2^&EOpX{9As0 zWj+P>{@}jxH)8|r;2HdupP!vie{sJ28b&bo!8`D^x}TE$%zXNb^X1p@0PJ86`dZyj z%ce7*{^oo+6%&~I!8hQy-vQ7E)0t0ybH4l%KltWOo~8cO`T=157JqL(oq_rC%ea&4 z2NcTJe-HgFjNg-gZ$6!Y`SMHrlj}Etf7?r!zQTPPSv}{so2e>Fjs1{gzk~LGeesX%r(Lh6rbhSo_n)@@G-FTQy93;l#E)hgP@d_SGvyCp0~o(Y;Ee8{ zdVUDbHm5`2taPUOY^MAGOw*>=s7=Gst=D+p+2yON!0%Hk` zz5mAhyT4lS*T3LS^WSxUy86q&GnoHxzQ6vm8)VS}_zuqG?+3td68_x;etQAdu@sc6 zQJ&5|4(I?~3d-QOAODHpZ=hlSg(lBZ!JZWCtHHSj`0Wh93-Uk)_S%zsJ~aD>{`A0~ z9{AG(e|q3g5B%wYKRxiL2Y$8(4w6bzchKuloQW#e&S3n+P- z8!ds-%f;TJ1>)v)##>gd{PdS2Oc3VaR`fr=`O8QIO(6(N!A?pr5C#6fc~Ge@N%Vvu zaoAX2&(a6eWy_q&UwOhU)|P3J0Qc%OdhzW=F4D|pt0E4osw;%<%Dn58hAWD^XnZD= z>9~H(3bmLtxpF?a7su6J7M*x1By7YSUbxGi)Ot0P77`}P3{)&5Un{KD?`-e?r21!4vTTnN(4Y6Lin?UkSM z`MXCTC1@4A4~mvz%Rh2&EwY))LeoT=*`tMoqcEXI>TZU9WTP#l?uFv+@Dn~b(>xh2 z;>B?;Tz2SR&KVb>vGiBSB`@U7VIWFSo=LDSb9F{GF^DbmWAfpms8Sx9OX4CnBJca3 zlj9(x!dIjN?OG1X4l*imJNvRCk}F%!?SOfiOq5y^mZW)jFL@a|r-@d#f7 z2gmU8L3IZq0ynIws=}~m^#@&C%J6QFo~Mo4V`>v7MI-_!EBMMtb%_M&kvAaN)@ZVw z+`toz&WG#HkWDjnZE!6nk{e-oFdL^$YnbOCN}JC&{$#$O27@|Tn-skXr)2ml2~O!5 zX+gYoxhoc7qoU?C^3~&!U?kRFtnSEecWuH0B0OvLodgUAi}8p1 zrO6RSXHH}DMc$&|?D004DiOVMHV8kXCP@7NKB zgaZq^^O<7PoKEp72kby@W0Z!Y*Ay{&vfg#C&gG@YVR9g?FEocMUi1gSN$+V+ayF45{a zuDZDTN}mS|;BO%gEf}pjBfN2-gIrU#G5~cucA;dokXW89%>AyXJJI z9X4UlIWA|ZYHgbI z5?oFk@A=Ik7lrEQPDH!H+b`7_Y~aDb_qa=B2^Y&Ow41cU=4WDd40dp5(QS-WMN-=Y z9g;6_-JdNU;|6cPwf$ak*aJIcwL@1n$#l~zi{c{EW?T;DaW*E8DYq?Umtz{nJ&w-M zEMyTDrC&9K$d|kZe2#ws6)L=7K+{ zQw{XnV6UC$6-rW0emqm8wJoeZK)wJIcV?dST}Z;G0Arq{dVDu0&4kd%N!3F1*;*pW zR&qUiFzK=@44#QGw7k1`3t_d8&*kBV->O##t|tonFc2YWrL7_eqg+=+k;!F-`^b8> z#KWCE8%u4k@EprxqiV$VmmtiWxDLgnGu$Vs<8rppV5EajBXL4nyyZM$SWVm!wnCj-B!Wjqj5-5dNXukI2$$|Bu3Lrw}z65Lc=1G z^-#WuQOj$hwNGG?*CM_TO8Bg-1+qc>J7k5c51U8g?ZU5n?HYor;~JIjoWH-G>AoUP ztrWWLbRNqIjW#RT*WqZgPJXU7C)VaW5}MiijYbABmzoru6EmQ*N8cVK7a3|aOB#O& zBl8JY2WKfmj;h#Q!pN%9o@VNLv{OUL?rixHwOZuvX7{IJ{(EdPpuVFoQqIOa7giLVkBOKL@^smUA!tZ1CKRK}#SSM)iQHk)*R~?M!qkCruaS!#oIL1c z?J;U~&FfH#*98^G?i}pA{ z9Jg36t4=%6mhY(quYq*vSxptes9qy|7xSlH?G=S@>u>Ebe;|LVhs~@+06N<4CViBk zUiY$thvX;>Tby6z9Y1edAMQaiH zm^r3v#$Q#2T=X>bsY#D%s!bhs^M9PMAcHbCc0FMHV{u-dwlL;a1eJ63v5U*?Q_8JO zT#50!RD619#j_Uf))0ooADz~*9&lN!bBDRUgE>Vud-i5ck%vT=r^yD*^?Mp@Q^v+V zG#-?gKlr}Eeqifb{|So?HM&g91P8|av8hQoCmQXkd?7wIJwb z_^v8bbg`SAn{I*4bH$u(RZ6*xUhuA~hc=8czK8SHEKTzSxgbwi~9(OqJB&gwb^l4+m`k*Q;_?>Y-APi1{k zAHQ)P)G)f|AyjSgcCFps)Fh6Bca*Xznq36!pV6Az&m{O8$wGFD? zY&O*3*J0;_EqM#jh6^gMQKpXV?#1?>$ml1xvh8nSN>-?H=V;nJIwB07YX$e6vLxH( zqYwQ>qxwR(i4f)DLd)-$P>T-no_c!LsN@)8`e;W@)-Hj0>nJ-}Kla4-ZdPJzI&Mce zv)V_j;(3ERN3_@I$N<^|4Lf`B;8n+bX@bHbcZTopEmDI*Jfl)-pFDvo6svPRoo@(x z);_{lY<;);XzT`dBFpRmGrr}z5u1=pC^S-{ce6iXQlLGcItwJ^mZx{m$&DA_oEZ)B{_bYPq-HA zcH8WGoBG(aBU_j)vEy+_71T34@4dmSg!|M8Vf92Zj6WH7Q7t#OHQqWgFE3ARt+%!T z?oLovLVlnf?2c7pTc)~cc^($_8nyKwsN`RA-23ed3sdj(ys%pjjM+9JrctL;dy8a( z@en&CQmnV(()bu|Y%G1-4a(6x{aLytn$T-;(&{QIJB9vMox11U-1HpD@d(QkaJdEb zG{)+6Dos_L+O3NpWo^=gR?evp|CqEG?L&Ut#D*KLaRFOgOEK(Kq1@!EGcTfo+%A&I z=dLbB+d$u{sh?u)xP{PF8L%;YPPW53+@{>5W=Jt#wQpN;0_HYdw1{ksf_XhO4#2F= zyPx6Lx2<92L-;L5PD`zn6zwIH`Jk($?Qw({erA$^bC;q33hv!d!>%wRhj# zal^hk+WGNg;rJtb-EB(?czvOM=H7dl=vblBwAv>}%1@{}mnpUznfq1cE^sgsL0*4I zJ##!*B?=vI_OEVis5o+_IwMIRrpQyT_Sq~ZU%oY7c5JMIADzpD!Upz9h@iWg_>>~j zOLS;wp^i$-E?4<_cp?RiS%Rd?i;f*mOz=~(&3lo<=@(nR!_Rqiprh@weZlL!t#NCc zO!QTcInq|%#>OVgobj{~ixEUec`E25zJ~*DofsQdzIa@5^nOXj2T;8O`l--(QyU^$t?TGY^7#&FQ+2SS3B#qK*k3`ye?8jUYSajE5iBbJls75CCc(m3dk{t?- zopcER9{Z?TC)mk~gpi^kbbu>b-+a{m#8-y2^p$ka4n60w;Sc2}HMf<8JUvhCL0B&Btk)T`ctE$*qNW8L$`7!r^9T+>=<=2qaq-;ll2{`{Rg zc5a0ZUI$oG&j-qVOuKa=*v4aY#IsoM+1|c4Z)<}lEDvy;5huB@1RJPquU2U*U-;gu z=En2m+qjBzR#DEJDO`WU)hdd{Vj%^0V*KoyZ|5lzV87&g_j~NCjwv0uQVqXOb*QrQ zy|Qn`hxx(58c70$E;L(X0uZZ72M1!6oeg)(cdKO ze0gDaTz+ohR-#d)NbAH4x{I(21yjwvBQfmpLu$)|m{XolbgF!pmsqJ#D}(ylp6uC> z{bqtcI#hT#HW=wl7>p!38sKsJ`r8}lt-q%Keqy%u(xk=yiIJiUw6|5IvkS+#?JTBl z8H5(Q?l#wzazujH!8o>1xtn8#_w+397*_cy8!pQGP%K(Ga3pAjsaTbbXJlQF_+m+-UpUUent@xM zg%jqLUExj~o^vQ3Gl*>wh=_gOr2*|U64_iXb+-111aH}$TjeajM+I20xw(((>fej-@CIz4S1pi$(#}P7`4({6QS2CaQS4NPENDp>sAqD z$bH4KGzXGffkJ7R>V>)>tC)uax{UsN*dbeNC*v}#8Y#OWYwL4t$ePR?VTyIs!wea+ z5Urmc)X|^`MG~*dS6pGSbU+gPJoq*^a=_>$n4|P^w$sMBBy@f*Z^Jg6?n5?oId6f{ z$LW4M|4m502z0t7g<#Bx%X;9<=)smFolV&(V^(7Cv2-sxbxopQ!)*#ZRhTBpx1)Fc zNm1T%bONzv6@#|dz(w02AH8OXe>kQ#1FMCzO}2J_mST)+ExmBr9cva-@?;wnmWMOk z{3_~EX_xadgJGv&H@zK_8{(x84`}+c?oSBX*Ge3VdfTt&F}yCpFP?CpW+BE^cWY0^ zb&uBN!Ja3UzYHK-CTyA5=L zEMW{l3Usky#ly=7px648W31UNV@K)&Ub&zP1c7%)`{);I4b0Q<)B}3;NMG2JH=X$U zfIW4)4n9ZM`-yRj67I)YSLDK)qfUJ_ij}a#aZN~9EXrh8eZY2&=uY%2N0UFF7<~%M zsB8=erOWZ>Ct_#^tHZ|*q`H;A)5;ycw*IcmVxi8_0Xk}aJA^ath+E;xg!x+As(M#0=)3!NJR6H&9+zd#iP(m0PIW8$ z1Y^VX`>jm`W!=WpF*{ioM?C9`yOR>@0q=u7o>BP-eSHqCgMDj!2anwH?s%i2p+Q7D zzszIf5XJpE)IG4;d_(La-xenmF(tgAxK`Y4sQ}BSJEPs6N_U2vI{8=0C_F?@7<(G; zo$~G=8p+076G;`}>{MQ>t>7cm=zGtfbdDXm6||jUU|?X?CaE?(<6bKDYKeHlz}DA8 zXT={X=yp_R;HfJ9h%?eWvQ!dRgz&Su*JfNt!Wu>|XfU&68iRikRrHRW|ZxzRR^`eIGt zIeiDgVS>IeExKVRWW8-=A=yA`}`)ZkWBrZD`hpWIxBGkh&f#ijr449~m`j6{4jiJ*C!oVA8ZC?$1RM#K(_b zL9TW)kN*Y4%^-qPpMP7d4)o?Nk#>aoYHT(*g)qmRUb?**F@pnNiy6Fv9rEiUqD(^O zzyS?nBrX63BTRYduaG(0VVG2yJRe%o&rVrLjbxTaAFTd8s;<<@Qs>u(<193R8>}2_ zuwp{7;H2a*X7_jryzriZXMg?bTuegABb^87@SsKkr2)0Gyiax8KQWstw^v#ix45EVrcEhr>!NMhprl$InQMzjSFH54x5k9qHc`@9uKQzvL4ihcq{^B zPrVR=o_ic%Y>6&rMN)hTZsI7I<3&`#(nl+3y3ys9A~&^=4?PL&nd8)`OfG#n zwAMN$1&>K++c{^|7<4P=2y(B{jJsQ0a#U;HTo4ZmWZYvI{+s;Td{Yzem%0*k#)vjpB zia;J&>}ICate44SFYY3vEelqStQWFihx%^vQ@Do(sOy7yR2@WNv7Y9I^yL=nZr3mb zXKV5t@=?-Sk|b{XMhA7ZGB@2hqsx}4xwCW!in#C zI@}scZlr3-NFJ@NFaJlhyfcw{k^vvtGl`N9xSo**rDW4S}i zM9{fMPWo%4wYDG~BZ18BD+}h|GQKc-g^{++3MY>}W_uq7jGHx{mwE9fZiPCoxN$+7 zrODGGJrOkcPQUB(FD5aoS4g~7#6NR^ma7-!>mHuJfY5kTe6PpNNKC9GGRiu^L31uG z$7v`*JknQHsYB!Tm_W{a32TM099djW%5e+j0Ve_ct}IM>XLF1Ap+YvcrLV=|CKo6S zb+9Nl3_YdKP6%Cxy@6TxZ>;4&nTneadr z_ES90ydCev)LV!dN=#(*f}|ZORFdvkYBni^aLbUk>BajeWIOcmHP#8S)*2U~QKI%S zyrLmtPqb&TphJ;>yAxri#;{uyk`JJqODDw%(Z=2`1uc}br^V%>j!gS)D*q*f_-qf8&D;W1dJgQMlaH5er zN2U<%Smb7==vE}dDI8K7cKz!vs^73o9f>2sgiTzWcwY|BMYHH5%Vn7#kiw&eItCqa zIkR2~Q}>X=Ar8W|^Ms41Fm8o6IB2_j60eOeBB1Br!boW7JnoeX6Gs)?7rW0^5psc- zjS16yb>dFn>KPOF;imD}e!enuIniFzv}n$m2#gCCv4jM#ArwlzZ$7@9&XkFxZ4n!V zj3dyiwW4Ki2QG{@i>yuZXQizw_OkZI^-3otXC{!(lUpJF33gI60ak;Uqitp74|B6I zgg{b=Iz}WkhCGj1M=hu4#Aw173YxIVbISaoc z-nLZC*6Tgivd5V`K%GxhBsp@SUU60-rfc$=wb>zdJzXS&-5(NRRodFk;Kxk!S(O(a0e7oY=E( zAyS;Ow?6Q&XA+cnkCb{28_1N8H#?J!*$MmIwLq^*T_9-z^&UE@A(z9oGYtFy6EZef LrJugUA?W`A8`#=m literal 0 HcmV?d00001 diff --git a/frontend/app/globals.css b/frontend/app/globals.css new file mode 100644 index 0000000..dc68007 --- /dev/null +++ b/frontend/app/globals.css @@ -0,0 +1,168 @@ +@import "tailwindcss"; +@import "tw-animate-css"; + +@custom-variant dark (&:is(.dark *)); + +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --font-sans: var(--font-geist-sans); + --font-mono: var(--font-geist-mono); + --color-sidebar-ring: var(--sidebar-ring); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar: var(--sidebar); + --color-chart-5: var(--chart-5); + --color-chart-4: var(--chart-4); + --color-chart-3: var(--chart-3); + --color-chart-2: var(--chart-2); + --color-chart-1: var(--chart-1); + --color-ring: var(--ring); + --color-input: var(--input); + --color-border: var(--border); + --color-destructive: var(--destructive); + --color-accent-foreground: var(--accent-foreground); + --color-accent: var(--accent); + --color-muted-foreground: var(--muted-foreground); + --color-muted: var(--muted); + --color-secondary-foreground: var(--secondary-foreground); + --color-secondary: var(--secondary); + --color-primary-foreground: var(--primary-foreground); + --color-primary: var(--primary); + --color-popover-foreground: var(--popover-foreground); + --color-popover: var(--popover); + --color-card-foreground: var(--card-foreground); + --color-card: var(--card); + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); + --animate-accordion-down: accordion-down 0.2s ease-out; + --animate-accordion-up: accordion-up 0.2s ease-out; + + @keyframes accordion-down { + from { + height: 0; + } + to { + height: var(--radix-accordion-content-height); + } + } + + @keyframes accordion-up { + from { + height: var(--radix-accordion-content-height); + } + to { + height: 0; + } + } +} + +:root { + --radius: 0.625rem; + --background: oklch(1 0 0); + --foreground: oklch(0.145 0 0); + --card: oklch(1 0 0); + --card-foreground: oklch(0.145 0 0); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.145 0 0); + --primary: oklch(0.205 0 0); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.97 0 0); + --secondary-foreground: oklch(0.205 0 0); + --muted: oklch(0.97 0 0); + --muted-foreground: oklch(0.556 0 0); + --accent: oklch(0.97 0 0); + --accent-foreground: oklch(0.205 0 0); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.922 0 0); + --input: oklch(0.922 0 0); + --ring: oklch(0.708 0 0); + --chart-1: oklch(0.646 0.222 41.116); + --chart-2: oklch(0.6 0.118 184.704); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.145 0 0); + --sidebar-primary: oklch(0.205 0 0); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.97 0 0); + --sidebar-accent-foreground: oklch(0.205 0 0); + --sidebar-border: oklch(0.922 0 0); + --sidebar-ring: oklch(0.708 0 0); +} + +.dark { + --background: oklch(0.145 0 0); + --foreground: oklch(0.985 0 0); + --card: oklch(0.205 0 0); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.205 0 0); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.922 0 0); + --primary-foreground: oklch(0.205 0 0); + --secondary: oklch(0.269 0 0); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.269 0 0); + --muted-foreground: oklch(0.708 0 0); + --accent: oklch(0.269 0 0); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.556 0 0); + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + --sidebar: oklch(0.205 0 0); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.269 0 0); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.556 0 0); +} + +@layer base { + * { + @apply border-border; + } + body { + @apply bg-background text-foreground; + font-feature-settings: "rlig" 1, "calt" 1; + } +} + +/* Todoist-inspired styles */ +.todoist-card { + @apply rounded-md border border-gray-100 shadow-sm hover:shadow-md transition-shadow; +} + +.todoist-task-row { + @apply flex items-center gap-3 px-3 py-2 rounded-md hover:bg-muted/30; +} + +.task-checkbox { + @apply h-5 w-5 rounded-full border border-gray-300 flex items-center justify-center hover:bg-muted/50; +} + +.task-checkbox.completed { + @apply bg-green-500 border-green-500; +} + +.airbnb-gradient { + background: linear-gradient(90deg, #ff5a5f 0%, #ff5a5f 50%, #fc642d 100%); +} + +.airbnb-button { + @apply bg-[#FF5A5F] hover:bg-[#FF5A5F]/90 text-white font-medium rounded-md px-4 py-2; +} \ No newline at end of file diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx new file mode 100644 index 0000000..6f9864a --- /dev/null +++ b/frontend/app/layout.tsx @@ -0,0 +1,36 @@ +import type React from "react"; +import { Inter } from "next/font/google"; +import { Toaster } from "sonner"; +import { QueryClientProvider } from "@/components/query-client-provider"; +import { ThemeProvider } from "@/components/theme-provider"; +import { AuthProvider } from "@/store/auth-provider"; +import "@/app/globals.css"; + +const inter = Inter({ subsets: ["latin"], variable: "--font-sans" }); + +export const metadata = { + title: "Todo App", + description: "A beautiful todo app with Airbnb-inspired design", + generator: "v0.dev", +}; + +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + + + + + {children} + + + + + + + ); +} diff --git a/frontend/app/loading.tsx b/frontend/app/loading.tsx new file mode 100644 index 0000000..0642157 --- /dev/null +++ b/frontend/app/loading.tsx @@ -0,0 +1,27 @@ +import { Skeleton } from "@/components/ui/skeleton" + +export default function Loading() { + return ( +
+
+ + +
+ +
+ + +
+ + + +
+ {Array(6) + .fill(0) + .map((_, i) => ( + + ))} +
+
+ ) +} diff --git a/frontend/app/login/page.tsx b/frontend/app/login/page.tsx new file mode 100644 index 0000000..6fbf75c --- /dev/null +++ b/frontend/app/login/page.tsx @@ -0,0 +1,221 @@ +"use client"; + +import type React from "react"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import Link from "next/link"; +import { toast } from "sonner"; +import { useAuth } from "@/hooks/use-auth"; +import { loginUserApi } from "@/services/api-auth"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Icons } from "@/components/icons"; +import { Checkbox } from "@/components/ui/checkbox"; +import Image from "next/image"; + +export default function LoginPage() { + const router = useRouter(); + const { login } = useAuth(); + const [isLoading, setIsLoading] = useState(false); + const [formData, setFormData] = useState({ + email: "", + password: "", + }); + const [showPassword, setShowPassword] = useState(false); + + const handleChange = (e: React.ChangeEvent) => { + const { name, value } = e.target; + setFormData((prev) => ({ ...prev, [name]: value })); + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setIsLoading(true); + + try { + const response = await loginUserApi(formData); + login(response.accessToken, { + id: "user-123", + username: "User", + email: formData.email, + emailVerified: true, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + toast.success("Logged in successfully"); + router.push("/todos"); + } catch (error) { + console.error("Login failed:", error); + toast.error("Login failed. Please check your credentials."); + } finally { + setIsLoading(false); + } + }; + + return ( +
+ {/* Left side - Image */} +
+
+ Background +
+
+ + TODO + +
+
+

+ Planning, +
+ Organizing, +

+
+
+
+
+
+
+
+
+ + {/* Right side - Form */} +
+
+
+ + TODO + + + Back to website + + +
+ +

+ Log in to your account +

+

+ Don't have an account?{" "} + + Sign up + +

+ +
+
+ + +
+
+
+ + + Forgot password? + +
+
+ + +
+
+ +
+ + +
+ + + +
+
+ + Or continue with + +
+ +
+ +
+
+
+
+
+ ); +} diff --git a/frontend/app/not-found.tsx b/frontend/app/not-found.tsx new file mode 100644 index 0000000..3fed22e --- /dev/null +++ b/frontend/app/not-found.tsx @@ -0,0 +1,34 @@ +import Link from "next/link" +import { Button } from "@/components/ui/button" +import { Icons } from "@/components/icons" + +export default function NotFound() { + return ( +
+
+
+ +
+

404

+

Page not found

+

+ Sorry, we couldn't find the page you're looking for. It might have been moved, deleted, or never existed. +

+
+ + +
+
+
+ ) +} diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx new file mode 100644 index 0000000..cc16d42 --- /dev/null +++ b/frontend/app/page.tsx @@ -0,0 +1,7 @@ +import { redirect } from "next/navigation" + +export default function Home() { + // In a real app, we'd check auth status server-side + // For now, just redirect to todos + redirect("/todos") +} diff --git a/frontend/app/profile/page.tsx b/frontend/app/profile/page.tsx new file mode 100644 index 0000000..de6ba24 --- /dev/null +++ b/frontend/app/profile/page.tsx @@ -0,0 +1,181 @@ +"use client"; + +import type React from "react"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { toast } from "sonner"; +import { useAuth } from "@/hooks/use-auth"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Icons } from "@/components/icons"; +import { Separator } from "@/components/ui/separator"; + +export default function ProfilePage() { + const router = useRouter(); + const { user, updateProfile } = useAuth(); + const [isLoading, setIsLoading] = useState(false); + const [username, setUsername] = useState(user?.username || ""); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!username.trim()) { + toast.error("Username cannot be empty"); + return; + } + + setIsLoading(true); + + try { + await updateProfile({ username }); + toast.success("Username updated successfully"); + router.push("/todos"); + } catch (error) { + console.error("Failed to update profile:", error); + toast.error("Failed to update profile. Please try again."); + } finally { + setIsLoading(false); + } + }; + + return ( +
+
+

Profile Settings

+ +
+ + + + Account Information + + Update your account information here. + + + +
+
+ +
+
+

{user?.username || "User"}

+

+ {user?.email || "user@example.com"} +

+
+
+ + + +
+
+ + setUsername(e.target.value)} + placeholder="Enter your username" + /> +

+ This is the name that will be displayed to other users. +

+
+ +
+ + +

+ Your email address cannot be changed. +

+
+
+
+ + + + +
+ + + + Account Security + + Manage your account security settings. + + + +
+
+

Password

+

+ Change your password +

+
+ +
+ +
+
+

Two-Factor Authentication

+

+ Add an extra layer of security to your account +

+
+ +
+
+
+ + + + Danger Zone + + Irreversible and destructive actions + + + +
+
+

Delete Account

+

+ Permanently delete your account and all of your data +

+
+ +
+
+
+
+ ); +} diff --git a/frontend/app/signup/page.tsx b/frontend/app/signup/page.tsx new file mode 100644 index 0000000..c825fa4 --- /dev/null +++ b/frontend/app/signup/page.tsx @@ -0,0 +1,258 @@ +"use client"; + +import type React from "react"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import Link from "next/link"; +import { toast } from "sonner"; +import { useAuth } from "@/hooks/use-auth"; +import { signupUserApi } from "@/services/api-auth"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Icons } from "@/components/icons"; +import { Checkbox } from "@/components/ui/checkbox"; +import Image from "next/image"; + +export default function SignupPage() { + const router = useRouter(); + const { login } = useAuth(); + const [isLoading, setIsLoading] = useState(false); + const [formData, setFormData] = useState({ + firstName: "", + lastName: "", + email: "", + password: "", + }); + const [showPassword, setShowPassword] = useState(false); + const [agreedToTerms, setAgreedToTerms] = useState(false); + + const handleChange = (e: React.ChangeEvent) => { + const { name, value } = e.target; + setFormData((prev) => ({ ...prev, [name]: value })); + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!agreedToTerms) { + toast.error("You must agree to the Terms & Conditions"); + return; + } + + setIsLoading(true); + + try { + const { firstName, lastName, email, password } = formData; + const username = `${firstName} ${lastName}`.trim(); + const user = await signupUserApi({ username, email, password }); + + // In a real app, we'd get a token back from signup or do a separate login + // For now, we'll simulate getting a token + login("dummy-token", user); + + toast.success("Account created successfully"); + router.push("/todos"); + } catch (error) { + console.error("Signup failed:", error); + toast.error("Signup failed. Please try again."); + } finally { + setIsLoading(false); + } + }; + + return ( +
+ {/* Left side - Image */} +
+
+ Background +
+
+ + TODO + +
+
+

+ Capturing Moments, +
+ Creating Memories +

+
+
+
+
+
+
+
+
+ + {/* Right side - Form */} +
+
+
+ + TODO + + + Back to website + + +
+ +

+ Create an account +

+

+ Already have an account?{" "} + + Log in + +

+ +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ +
+ + +
+
+ +
+ + setAgreedToTerms(checked as boolean) + } + className="border-gray-300 data-[state=checked]:bg-[#FF5A5F]" + /> + +
+ + + +
+
+ + Or register with + +
+ +
+ +
+
+
+
+
+ ); +} diff --git a/frontend/app/todos/kanban/page.tsx b/frontend/app/todos/kanban/page.tsx new file mode 100644 index 0000000..2df8a18 --- /dev/null +++ b/frontend/app/todos/kanban/page.tsx @@ -0,0 +1,139 @@ +"use client"; + +import { useState } from "react"; +import { toast } from "sonner"; +import { DndContext, type DragEndEvent, closestCenter } from "@dnd-kit/core"; +import { + SortableContext, + verticalListSortingStrategy, +} from "@dnd-kit/sortable"; +import { useTodos, useUpdateTodo } from "@/hooks/use-todos"; +import { useTags } from "@/hooks/use-tags"; +import { KanbanColumn } from "@/components/kanban-column"; +import { TodoForm } from "@/components/todo-form"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Icons } from "@/components/icons"; + +export default function KanbanPage() { + const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false); + const { data: todos = [], isLoading } = useTodos(); + const { data: tags = [] } = useTags(); + const updateTodoMutation = useUpdateTodo(); + + const pendingTodos = todos.filter((todo) => todo.status === "pending"); + const inProgressTodos = todos.filter((todo) => todo.status === "in-progress"); + const completedTodos = todos.filter((todo) => todo.status === "completed"); + + const handleDragEnd = (event: DragEndEvent) => { + const { active, over } = event; + + if (!over) return; + + const todoId = active.id as string; + const newStatus = over.id as "pending" | "in-progress" | "completed"; + + const todo = todos.find((t) => t.id === todoId); + if (!todo || todo.status === newStatus) return; + + updateTodoMutation.mutate( + { + id: todoId, + todo: { status: newStatus }, + }, + { + onSuccess: () => { + toast.success(`Todo moved to ${newStatus.replace("-", " ")}`); + }, + onError: () => { + toast.error("Failed to update todo status"); + }, + } + ); + }; + + const handleCreateTodo = async () => { + try { + // This would be handled by the createTodo mutation in a real app + toast.success("Todo created successfully"); + setIsCreateDialogOpen(false); + } catch { + toast.error("Failed to create todo"); + } + }; + + return ( +
+
+

Kanban Board

+ + + + + + + Create New Todo + + Add a new task to your todo list + + + + + +
+ + +
+ t.id)} + strategy={verticalListSortingStrategy} + > + + + + t.id)} + strategy={verticalListSortingStrategy} + > + + + + t.id)} + strategy={verticalListSortingStrategy} + > + + +
+
+
+ ); +} diff --git a/frontend/app/todos/layout.tsx b/frontend/app/todos/layout.tsx new file mode 100644 index 0000000..ea001fb --- /dev/null +++ b/frontend/app/todos/layout.tsx @@ -0,0 +1,41 @@ +"use client"; + +import { useEffect } from "react"; +import { useRouter } from "next/navigation"; +import { useAuthStore } from "@/store/auth-store"; +import { Navbar } from "@/components/navbar"; + +export default function TodosLayout({ + children, +}: { + children: React.ReactNode; +}) { + const { isAuthenticated, hydrated } = useAuthStore(); + const router = useRouter(); + + useEffect(() => { + if (hydrated && !isAuthenticated) { + router.push("/login"); + } + }, [hydrated, isAuthenticated, router]); + + if (!hydrated) { + return ( +
+
+
+ ); + } + + if (!isAuthenticated) { + router.push("/login"); + return null; + } + + return ( +
+ +
{children}
+
+ ); +} diff --git a/frontend/app/todos/list/page.tsx b/frontend/app/todos/list/page.tsx new file mode 100644 index 0000000..f057a13 --- /dev/null +++ b/frontend/app/todos/list/page.tsx @@ -0,0 +1,344 @@ +"use client"; + +import { useState } from "react"; +import { toast } from "sonner"; +import { motion } from "framer-motion"; +import { + useTodos, + useCreateTodo, + useUpdateTodo, + useDeleteTodo, +} from "@/hooks/use-todos"; +import { useTags } from "@/hooks/use-tags"; +import { TodoCard } from "@/components/todo-card"; +import { TodoRow } from "@/components/todo-row"; +import { TodoForm } from "@/components/todo-form"; +import { Button } from "@/components/ui/button"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Icons } from "@/components/icons"; +import type { Tag, Todo } from "@/services/api-types"; + +export default function TodoListPage() { + const [status, setStatus] = useState(undefined); + const [tagFilter, setTagFilter] = useState(undefined); + const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false); + const [searchQuery, setSearchQuery] = useState(""); + const [viewMode, setViewMode] = useState<"grid" | "list">("grid"); + + const { + data: todos = [], + isLoading, + isError, + } = useTodos({ status, tagId: tagFilter }); + const { data: tags = [] } = useTags(); + const createTodoMutation = useCreateTodo(); + const updateTodoMutation = useUpdateTodo(); + const deleteTodoMutation = useDeleteTodo(); + + const handleCreateTodo = async (todo: Partial) => { + try { + await createTodoMutation.mutateAsync(todo); + setIsCreateDialogOpen(false); + toast.success("Todo created successfully"); + } catch { + toast.error("Failed to create todo"); + } + }; + + const handleUpdateTodo = async (id: string, todo: Partial) => { + try { + await updateTodoMutation.mutateAsync({ id, todo }); + toast.success("Todo updated successfully"); + } catch { + toast.error("Failed to update todo"); + } + }; + + const handleDeleteTodo = async (id: string) => { + try { + await deleteTodoMutation.mutateAsync(id); + toast.success("Todo deleted successfully"); + } catch { + toast.error("Failed to delete todo"); + } + }; + + const filteredTodos = todos.filter( + (todo) => + todo.title.toLowerCase().includes(searchQuery.toLowerCase()) || + (todo.description && + todo.description.toLowerCase().includes(searchQuery.toLowerCase())) + ); + + if (isError) { + return ( +
+
+ +

+ Error loading todos +

+

+ Please try again later +

+ +
+
+ ); + } + + return ( +
+
+

Todos

+
+
+ + +
+ + + + + + + Create New Todo + + Add a new task to your todo list + + + + + +
+
+ +
+
+ setSearchQuery(e.target.value)} + className="max-w-md" + /> +
+
+ +
+
+ + + setStatus(value === "all" ? undefined : value) + } + > + + All + Pending + In Progress + + + + + + todo.status === "pending")} + tags={tags} + isLoading={isLoading} + onUpdate={handleUpdateTodo} + onDelete={handleDeleteTodo} + viewMode={viewMode} + /> + + + todo.status === "in-progress" + )} + tags={tags} + isLoading={isLoading} + onUpdate={handleUpdateTodo} + onDelete={handleDeleteTodo} + viewMode={viewMode} + /> + + +
+ ); +} + +function TodoList({ + todos, + tags, + isLoading, + onUpdate, + onDelete, + viewMode, +}: { + todos: Todo[]; + tags: Tag[]; + isLoading: boolean; + onUpdate: (id: string, todo: Partial) => void; + onDelete: (id: string) => void; + viewMode: "grid" | "list"; +}) { + const container = { + hidden: { opacity: 0 }, + show: { + opacity: 1, + transition: { + staggerChildren: 0.05, + }, + }, + }; + + const item = { + hidden: { opacity: 0, y: 20 }, + show: { opacity: 1, y: 0 }, + }; + + if (isLoading) { + if (viewMode === "grid") { + return ( +
+ {[1, 2, 3, 4, 5, 6].map((i) => ( +
+ ))} +
+ ); + } else { + return ( +
+ {[1, 2, 3, 4, 5, 6].map((i) => ( +
+ ))} +
+ ); + } + } + + if (todos.length === 0) { + return ( +
+ +

+ No todos found +

+

+ Create one to get started! +

+
+ ); + } + + if (viewMode === "grid") { + return ( + + {todos.map((todo) => ( + + onUpdate(todo.id, updatedTodo)} + onDelete={() => onDelete(todo.id)} + /> + + ))} + + ); + } else { + return ( + + {todos.map((todo) => ( + + onUpdate(todo.id, updatedTodo)} + onDelete={() => onDelete(todo.id)} + /> + + ))} + + ); + } +} diff --git a/frontend/app/todos/notifications/page.tsx b/frontend/app/todos/notifications/page.tsx new file mode 100644 index 0000000..a1e7e1f --- /dev/null +++ b/frontend/app/todos/notifications/page.tsx @@ -0,0 +1,232 @@ +"use client"; + +import { useState } from "react"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardTitle, +} from "@/components/ui/card"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Icons } from "@/components/icons"; +import { Badge } from "@/components/ui/badge"; + +// Mock notification data - in a real app, this would come from an API +const mockNotifications = [ + { + id: 1, + title: "New task assigned", + description: "You have been assigned a new task 'Update documentation'", + time: "5 minutes ago", + read: false, + type: "task", + }, + { + id: 2, + title: "Task completed", + description: "Your task 'Update documentation' has been completed", + time: "1 hour ago", + read: false, + type: "task", + }, + { + id: 3, + title: "Meeting reminder", + description: "Team meeting starts in 30 minutes", + time: "2 hours ago", + read: false, + type: "reminder", + }, + { + id: 4, + title: "New comment", + description: "John commented on your task 'Design homepage'", + time: "1 day ago", + read: true, + type: "comment", + }, + { + id: 5, + title: "Task deadline approaching", + description: "Task 'Finalize project proposal' is due tomorrow", + time: "1 day ago", + read: true, + type: "reminder", + }, + { + id: 6, + title: "System update", + description: "The system will be updated tonight at 2 AM", + time: "2 days ago", + read: true, + type: "system", + }, +]; + +export default function NotificationsPage() { + const [notifications, setNotifications] = useState(mockNotifications); + const [activeTab, setActiveTab] = useState("all"); + const [isLoading, setIsLoading] = useState(false); + + const unreadCount = notifications.filter((n) => !n.read).length; + + const filteredNotifications = notifications.filter((notification) => { + if (activeTab === "all") return true; + if (activeTab === "unread") return !notification.read; + if (activeTab === "read") return notification.read; + return true; + }); + + const markAsRead = (id: number) => { + setNotifications( + notifications.map((notification) => + notification.id === id ? { ...notification, read: true } : notification + ) + ); + toast.success("Notification marked as read"); + }; + + const markAllAsRead = () => { + setIsLoading(true); + // Simulate API call + setTimeout(() => { + setNotifications( + notifications.map((notification) => ({ ...notification, read: true })) + ); + setIsLoading(false); + toast.success("All notifications marked as read"); + }, 500); + }; + + const getNotificationIcon = (type: string) => { + switch (type) { + case "task": + return ; + case "reminder": + return ; + case "comment": + return ; + case "system": + return ; + default: + return ; + } + }; + + return ( +
+
+
+

Notifications

+

+ You have {unreadCount} unread notification{unreadCount !== 1 && "s"} +

+
+ {unreadCount > 0 && ( + + )} +
+ + + + + All + {notifications.length} + + + Unread + {unreadCount} + + + Read + + {notifications.length - unreadCount} + + + + + + {filteredNotifications.length === 0 ? ( + + + +

+ No notifications +

+

+ {activeTab === "unread" + ? "You have no unread notifications" + : activeTab === "read" + ? "You have no read notifications" + : "You have no notifications"} +

+
+
+ ) : ( +
+ {filteredNotifications.map((notification) => ( + + +
+ {getNotificationIcon(notification.type)} +
+
+
+ + {notification.title} + +
+ + {notification.time} + + {!notification.read && ( + + )} +
+
+ + {notification.description} + +
+
+
+ ))} +
+ )} +
+
+
+ ); +} diff --git a/frontend/app/todos/page.tsx b/frontend/app/todos/page.tsx new file mode 100644 index 0000000..060831c --- /dev/null +++ b/frontend/app/todos/page.tsx @@ -0,0 +1,14 @@ +"use client" + +import { useRouter } from "next/navigation" +import { useEffect } from "react" + +export default function TodosPage() { + const router = useRouter() + + useEffect(() => { + router.push("/todos/list") + }, [router]) + + return null +} diff --git a/frontend/app/todos/tags/page.tsx b/frontend/app/todos/tags/page.tsx new file mode 100644 index 0000000..1a1305c --- /dev/null +++ b/frontend/app/todos/tags/page.tsx @@ -0,0 +1,298 @@ +"use client"; + +import { useState } from "react"; +import { toast } from "sonner"; +import { + useTags, + useCreateTag, + useUpdateTag, + useDeleteTag, +} from "@/hooks/use-tags"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Icons } from "@/components/icons"; +import type { Tag } from "@/services/api-types"; + +export default function TagsPage() { + const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false); + const [isEditDialogOpen, setIsEditDialogOpen] = useState(false); + const [currentTag, setCurrentTag] = useState(null); + const [formData, setFormData] = useState({ + name: "", + color: "#FF5A5F", + icon: "", + }); + + const { data: tags = [], isLoading } = useTags(); + const createTagMutation = useCreateTag(); + const updateTagMutation = useUpdateTag(); + const deleteTagMutation = useDeleteTag(); + + const handleChange = (e: React.ChangeEvent) => { + const { name, value } = e.target; + setFormData((prev) => ({ ...prev, [name]: value })); + }; + + const handleCreateTag = async (e: React.FormEvent) => { + e.preventDefault(); + try { + await createTagMutation.mutateAsync(formData); + setIsCreateDialogOpen(false); + setFormData({ name: "", color: "#FF5A5F", icon: "" }); + toast.success("Tag created successfully"); + } catch (error) { + toast.error("Failed to create tag"); + } + }; + + const handleEditTag = (tag: Tag) => { + setCurrentTag(tag); + setFormData({ + name: tag.name, + color: tag.color || "#FF5A5F", + icon: tag.icon || "", + }); + setIsEditDialogOpen(true); + }; + + const handleUpdateTag = async (e: React.FormEvent) => { + e.preventDefault(); + if (!currentTag) return; + + try { + await updateTagMutation.mutateAsync({ + id: currentTag.id, + tag: formData, + }); + setIsEditDialogOpen(false); + toast.success("Tag updated successfully"); + } catch (error) { + toast.error("Failed to update tag"); + } + }; + + const handleDeleteTag = async (id: string) => { + try { + await deleteTagMutation.mutateAsync(id); + toast.success("Tag deleted successfully"); + } catch (error) { + toast.error("Failed to delete tag"); + } + }; + + return ( +
+
+

Labels

+ + + + + + + Create New Label + + Add a new label to organize your todos + + +
+
+ + +
+
+ +
+ + +
+
+
+ + +
+ +
+
+
+
+ +
+
+

Labels

+
+ {isLoading ? ( +
+ {Array(6) + .fill(0) + .map((_, i) => ( +
+ ))} +
+ ) : tags.length === 0 ? ( +
+ +

+ No labels found +

+

+ Create a label to organize your todos +

+ +
+ ) : ( +
+ {tags.map((tag) => ( +
handleEditTag(tag)} + > +
+
+ +
+ {tag.name} +
+
+ + {Math.floor(Math.random() * 10)} + + +
+
+ ))} +
+ )} +
+ + + + + Edit Label + Update your label details + +
+
+ + +
+
+ +
+ + +
+
+
+ + +
+
+ + +
+
+
+
+
+ ); +} diff --git a/frontend/components.json b/frontend/components.json new file mode 100644 index 0000000..335484f --- /dev/null +++ b/frontend/components.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "", + "css": "app/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "iconLibrary": "lucide" +} \ No newline at end of file diff --git a/frontend/components/icons.tsx b/frontend/components/icons.tsx new file mode 100644 index 0000000..696d6ef --- /dev/null +++ b/frontend/components/icons.tsx @@ -0,0 +1,103 @@ +import { + IconAlertCircle, + IconArrowRight, + IconCheck, + IconChevronLeft, + IconChevronRight, + IconCommand, + IconCreditCard, + IconFile, + IconFileText, + IconHelpCircle, + IconPhoto, + IconLoader2, + IconMoon, + IconDotsVertical, + IconPizza, + IconPlus, + IconSettings, + IconSun, + IconTrash, + IconUser, + IconX, + IconEdit, + IconCalendar, + IconTag, + IconLogout, + IconHome, + IconList, + IconLayoutKanban, + IconInbox, + IconSearch, + IconEye, + IconEyeOff, + IconBrandGoogle, + IconBrandApple, + IconPaperclip, + IconImageInPicture, + IconLayoutGrid, + IconBell, + IconRefresh, + IconCheckbox, + IconStar, + IconMessageCircle, + IconArrowLeft, + IconClock, + IconLoader, + IconCircle, + type Icon as TablerIconType, +} from "@tabler/icons-react"; + +export type Icon = TablerIconType; + +export const Icons = { + logo: IconCommand, + close: IconX, + spinner: IconLoader2, + chevronLeft: IconChevronLeft, + chevronRight: IconChevronRight, + trash: IconTrash, + edit: IconEdit, + post: IconFileText, + page: IconFile, + media: IconPhoto, + settings: IconSettings, + billing: IconCreditCard, + ellipsis: IconDotsVertical, + add: IconPlus, + warning: IconAlertCircle, + user: IconUser, + arrowRight: IconArrowRight, + help: IconHelpCircle, + pizza: IconPizza, + sun: IconSun, + moon: IconMoon, + calendar: IconCalendar, + tag: IconTag, + logout: IconLogout, + home: IconHome, + list: IconList, + kanban: IconLayoutKanban, + inbox: IconInbox, + search: IconSearch, + plus: IconPlus, + check: IconCheck, + eye: IconEye, + eyeOff: IconEyeOff, + google: IconBrandGoogle, + apple: IconBrandApple, + paperclip: IconPaperclip, + image: IconImageInPicture, + layoutGrid: IconLayoutGrid, + x: IconX, + bell: IconBell, + refresh: IconRefresh, + checkSquare: IconCheckbox, + star: IconStar, + messageCircle: IconMessageCircle, + arrowLeft: IconArrowLeft, + clock: IconClock, + loader: IconLoader, + circle: IconCircle, + moreVertical: IconDotsVertical, +}; diff --git a/frontend/components/kanban-column.tsx b/frontend/components/kanban-column.tsx new file mode 100644 index 0000000..e86ec0c --- /dev/null +++ b/frontend/components/kanban-column.tsx @@ -0,0 +1,99 @@ +"use client"; + +import { useDroppable } from "@dnd-kit/core"; +import { motion, AnimatePresence } from "framer-motion"; +import { TodoCard } from "@/components/todo-card"; +import type { Todo, Tag } from "@/services/api-types"; + +interface KanbanColumnProps { + id: string; + title: string; + todos: Todo[]; + tags: Tag[]; + isLoading: boolean; +} + +export function KanbanColumn({ + id, + title, + todos, + tags, + isLoading, +}: KanbanColumnProps) { + const { setNodeRef, isOver } = useDroppable({ + id, + }); + + const getColumnColor = () => { + switch (id) { + case "pending": + return "border-yellow-200 bg-yellow-50 dark:bg-yellow-950/20 dark:border-yellow-900/50"; + case "in-progress": + return "border-blue-200 bg-blue-50 dark:bg-blue-950/20 dark:border-blue-900/50"; + case "completed": + return "border-green-200 bg-green-50 dark:bg-green-950/20 dark:border-green-900/50"; + default: + return "border-gray-200 bg-gray-50 dark:bg-gray-800/20 dark:border-gray-700"; + } + }; + + return ( +
+
+

+ {title} +

+
+ {todos.length} +
+
+ +
+ {isLoading ? ( + Array(3) + .fill(0) + .map((_, i) => ( +
+ )) + ) : todos.length === 0 ? ( +
+

+ No todos in this column +

+

+ Drag and drop tasks here +

+
+ ) : ( + + {todos.map((todo) => ( + + {}} + onDelete={() => {}} + isDraggable={true} + /> + + ))} + + )} +
+
+ ); +} diff --git a/frontend/components/multi-select.tsx b/frontend/components/multi-select.tsx new file mode 100644 index 0000000..2ac77dc --- /dev/null +++ b/frontend/components/multi-select.tsx @@ -0,0 +1,121 @@ +"use client" + +import * as React from "react" +import { X } from "lucide-react" +import { Badge } from "@/components/ui/badge" +import { Command, CommandGroup, CommandItem } from "@/components/ui/command" +import { Command as CommandPrimitive } from "cmdk" + +interface MultiSelectProps { + options: { label: string; value: string }[] + selected: string[] + onChange: (selected: string[]) => void + placeholder?: string +} + +export function MultiSelect({ options, selected, onChange, placeholder = "Select options" }: MultiSelectProps) { + const inputRef = React.useRef(null) + const [open, setOpen] = React.useState(false) + const [inputValue, setInputValue] = React.useState("") + + const handleUnselect = (value: string) => { + onChange(selected.filter((s) => s !== value)) + } + + const handleSelect = (value: string) => { + if (selected.includes(value)) { + onChange(selected.filter((s) => s !== value)) + } else { + onChange([...selected, value]) + } + } + + const handleKeyDown = (e: React.KeyboardEvent) => { + const input = inputRef.current + if (input) { + if (e.key === "Delete" || e.key === "Backspace") { + if (input.value === "" && selected.length > 0) { + onChange(selected.slice(0, -1)) + } + } + if (e.key === "Escape") { + input.blur() + } + } + } + + const selectedOptions = options.filter((option) => selected.includes(option.value)) + + return ( + +
+
+ {selectedOptions.map((option) => ( + + {option.label} + + + ))} + setOpen(false)} + onFocus={() => setOpen(true)} + placeholder={selected.length === 0 ? placeholder : undefined} + className="ml-2 flex-1 bg-transparent outline-none placeholder:text-muted-foreground" + /> +
+
+
+ {open && options.length > 0 && ( +
+ + {options.map((option) => { + const isSelected = selected.includes(option.value) + return ( + { + e.preventDefault() + e.stopPropagation() + }} + onSelect={() => handleSelect(option.value)} + className={`flex items-center gap-2 ${isSelected ? "bg-muted" : ""}`} + > +
+ + + +
+ {option.label} +
+ ) + })} +
+
+ )} +
+
+ ) +} diff --git a/frontend/components/navbar.tsx b/frontend/components/navbar.tsx new file mode 100644 index 0000000..29a503b --- /dev/null +++ b/frontend/components/navbar.tsx @@ -0,0 +1,226 @@ +"use client"; + +import { useState } from "react"; +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { useAuth } from "@/hooks/use-auth"; +import { cn } from "@/lib/utils"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Icons } from "@/components/icons"; +import { Badge } from "@/components/ui/badge"; + +export function Navbar() { + const pathname = usePathname(); + const { user, logout } = useAuth(); + const [notificationCount, setNotificationCount] = useState(3); + + const notifications = [ + { + id: 1, + title: "New task assigned", + description: "You have been assigned a new task", + time: "5 minutes ago", + read: false, + }, + { + id: 2, + title: "Task completed", + description: "Your task 'Update documentation' has been completed", + time: "1 hour ago", + read: false, + }, + { + id: 3, + title: "Meeting reminder", + description: "Team meeting starts in 30 minutes", + time: "2 hours ago", + read: false, + }, + ]; + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const markAsRead = (id: number) => { + setNotificationCount(Math.max(0, notificationCount - 1)); + }; + + const markAllAsRead = () => { + setNotificationCount(0); + }; + + return ( +
+
+
+ + + Todo App + + +
+
+ {/* Notifications Dropdown */} + + + + + + + Notifications + {notificationCount > 0 && ( + + )} + + + {notifications.length === 0 ? ( +
+ No new notifications +
+ ) : ( + notifications.map((notification) => ( + markAsRead(notification.id)} + > +
+ {notification.title} + + {notification.time} + +
+

+ {notification.description} +

+
+ )) + )} + + + + View all notifications + + +
+
+ + + + + + + {user?.username || "User"} + + + + + Profile + + + + + + List View + + + + + + Kanban View + + + + + + Manage Tags + + + + + + Log out + + + +
+
+
+ ); +} diff --git a/frontend/components/query-client-provider.tsx b/frontend/components/query-client-provider.tsx new file mode 100644 index 0000000..b6b74f1 --- /dev/null +++ b/frontend/components/query-client-provider.tsx @@ -0,0 +1,29 @@ +"use client" + +import type React from "react" + +import { useState } from "react" +import { QueryClient, QueryClientProvider as TanstackQueryClientProvider } from "@tanstack/react-query" +import { ReactQueryDevtools } from "@tanstack/react-query-devtools" + +export function QueryClientProvider({ children }: { children: React.ReactNode }) { + const [queryClient] = useState( + () => + new QueryClient({ + defaultOptions: { + queries: { + staleTime: 60 * 1000, // 1 minute + refetchOnWindowFocus: false, + retry: 1, + }, + }, + }), + ) + + return ( + + {children} + + + ) +} diff --git a/frontend/components/theme-provider.tsx b/frontend/components/theme-provider.tsx new file mode 100644 index 0000000..55c2f6e --- /dev/null +++ b/frontend/components/theme-provider.tsx @@ -0,0 +1,11 @@ +'use client' + +import * as React from 'react' +import { + ThemeProvider as NextThemesProvider, + type ThemeProviderProps, +} from 'next-themes' + +export function ThemeProvider({ children, ...props }: ThemeProviderProps) { + return {children} +} diff --git a/frontend/components/todo-card.tsx b/frontend/components/todo-card.tsx new file mode 100644 index 0000000..2ffe457 --- /dev/null +++ b/frontend/components/todo-card.tsx @@ -0,0 +1,439 @@ +"use client"; + +import { useState } from "react"; +import { toast } from "sonner"; +import { useSortable } from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import { + Card, + CardContent, + CardTitle, + CardDescription, + CardHeader, +} from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, +} from "@/components/ui/dialog"; +import { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, +} from "@/components/ui/dropdown-menu"; +import { TodoForm } from "@/components/todo-form"; +import { Icons } from "@/components/icons"; +import { cn } from "@/lib/utils"; +import type { Todo, Tag } from "@/services/api-types"; +import Image from "next/image"; + +interface TodoCardProps { + todo: Todo; + tags: Tag[]; + onUpdate: (todo: Partial) => void; + onDelete: () => void; + isDraggable?: boolean; +} + +export function TodoCard({ + todo, + tags, + onUpdate, + onDelete, + isDraggable = false, +}: TodoCardProps) { + const [isEditDialogOpen, setIsEditDialogOpen] = useState(false); + const [isViewDialogOpen, setIsViewDialogOpen] = useState(false); + + // Drag and drop logic + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ + id: todo.id, + disabled: !isDraggable, + }); + + const style = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.5 : 1, + }; + + // Style helpers + const getStatusColor = (status: string) => { + switch (status) { + case "pending": + return "border-l-4 border-l-amber-500"; + case "in-progress": + return "border-l-4 border-l-sky-500"; + case "completed": + return "border-l-4 border-l-emerald-500"; + default: + return "border-l-4 border-l-slate-400"; + } + }; + + const getStatusIcon = (status: string) => { + switch (status) { + case "pending": + return ; + case "in-progress": + return ; + case "completed": + return ; + default: + return ; + } + }; + + const todoTags = tags.filter((tag) => todo.tagIds.includes(tag.id)); + const hasImage = !!todo.image; + const hasAttachments = todo.attachments && todo.attachments.length > 0; + const hasSubtasks = todo.subtasks && todo.subtasks.length > 0; + const completedSubtasks = todo.subtasks + ? todo.subtasks.filter((subtask) => subtask.completed).length + : 0; + + const handleStatusToggle = () => { + const newStatus = todo.status === "completed" ? "pending" : "completed"; + onUpdate({ status: newStatus }); + }; + + const formatDate = (dateString?: string | null) => { + if (!dateString) return ""; + const date = new Date(dateString); + return date.toLocaleDateString(); + }; + + return ( + <> + + + {/* Left icon, like notification card */} +
{getStatusIcon(todo.status)}
+ {/* Main content */} +
+ +
+ + {todo.title} + + {!isDraggable && ( + + + + + + setIsViewDialogOpen(true)} + > + + View details + + setIsEditDialogOpen(true)} + > + + Edit + + + + Delete + + + + )} +
+ {/* Actions and deadline on a new line */} +
+ {todo.deadline && ( + + + {formatDate(todo.deadline)} + + )} +
+
+ + {todo.description} + + {/* Tags and indicators */} +
+ {todoTags.map((tag) => ( + + {tag.name} + + ))} + {hasSubtasks && ( + + + {completedSubtasks}/{todo.subtasks.length} + + )} + {hasAttachments && ( + + + {todo.attachments.length} + + )} + {hasImage && ( + + + + )} +
+ {/* Bottom row: created date and status toggle */} +
+ + {todo.createdAt ? formatDate(todo.createdAt) : ""} + + +
+
+
+
+ + {/* Edit Dialog */} + + + + Edit Todo + + Make changes to your task and save when you're done + + + { + onUpdate(updatedTodo); + setIsEditDialogOpen(false); + toast.success("Todo updated successfully"); + }} + /> + + + + {/* View Dialog */} + + + +
+ +
+ {todo.status.replace("-", " ")} + + {todo.deadline && ( + + + {formatDate(todo.deadline)} + + )} +
+ + {todo.title} + + + Created {formatDate(todo.createdAt)} + + + {hasImage && ( +
+ {todo.title} +
+ )} +
+ {todo.description && ( +
+ {todo.description} +
+ )} + {todoTags.length > 0 && ( +
+

Tags

+
+ {todoTags.map((tag) => ( + + {tag.name} + + ))} +
+
+ )} + {hasAttachments && ( +
+

Attachments

+
+ {todo.attachments.map((a, i) => ( +
+ + {a} +
+ ))} +
+
+ )} + {hasSubtasks && ( +
+

+ Subtasks ({completedSubtasks}/{todo.subtasks.length}) +

+
    + {todo.subtasks.map((subtask) => ( +
  • +
    + {subtask.completed && ( + + )} +
    + + {subtask.description} + +
  • + ))} +
+
+ )} +
+
+ + +
+ +
+ + ); +} diff --git a/frontend/components/todo-form.tsx b/frontend/components/todo-form.tsx new file mode 100644 index 0000000..6263f82 --- /dev/null +++ b/frontend/components/todo-form.tsx @@ -0,0 +1,196 @@ +"use client"; + +import type React from "react"; +import Image from "next/image"; + +import { useState, useEffect } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { MultiSelect } from "@/components/multi-select"; +import { Icons } from "@/components/icons"; +import type { Todo, Tag } from "@/services/api-types"; + +interface TodoFormProps { + todo?: Todo; + tags: Tag[]; + onSubmit: (todo: Partial) => void; +} + +export function TodoForm({ todo, tags, onSubmit }: TodoFormProps) { + const [formData, setFormData] = useState>({ + title: "", + description: "", + status: "pending", + deadline: undefined, + tagIds: [], + image: null, + }); + const [imagePreview, setImagePreview] = useState(null); + + useEffect(() => { + if (todo) { + setFormData({ + title: todo.title, + description: todo.description || "", + status: todo.status, + deadline: todo.deadline, + tagIds: todo.tagIds || [], + image: todo.image || null, + }); + + if (todo.image) { + setImagePreview(todo.image); + } + } + }, [todo]); + + const handleChange = ( + e: React.ChangeEvent + ) => { + const { name, value } = e.target; + setFormData((prev) => ({ ...prev, [name]: value })); + }; + + const handleSelectChange = (name: string, value: string) => { + setFormData((prev) => ({ ...prev, [name]: value })); + }; + + const handleTagsChange = (selected: string[]) => { + setFormData((prev) => ({ ...prev, tagIds: selected })); + }; + + const handleImageChange = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + // In a real app, we would upload the file to a server and get a URL back + // For now, we'll create a local object URL + const imageUrl = URL.createObjectURL(file); + setImagePreview(imageUrl); + setFormData((prev) => ({ ...prev, image: imageUrl })); + }; + + const handleRemoveImage = () => { + setImagePreview(null); + setFormData((prev) => ({ ...prev, image: null })); + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + onSubmit(formData); + }; + + return ( +
+
+ + +
+
+ +