diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..bf8c4b4 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,6 @@ +{ + "enabledMcpjsonServers": [ + "ios-simulator" + ], + "enableAllProjectMcpServers": true +} diff --git a/.env.example b/.env.example index 8705c9a..7f1da07 100644 --- a/.env.example +++ b/.env.example @@ -47,3 +47,9 @@ GORUSH_URL=http://localhost:8088 # Admin Panel ADMIN_PORT=9000 + +# Storage Settings (File Uploads) +STORAGE_UPLOAD_DIR=./uploads +STORAGE_BASE_URL=/uploads +STORAGE_MAX_FILE_SIZE=10485760 +STORAGE_ALLOWED_TYPES=image/jpeg,image/png,image/gif,image/webp,application/pdf diff --git a/Dockerfile b/Dockerfile index d17d238..25c179c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,21 @@ -# Build stage +# Admin panel build stage +FROM node:20-alpine AS admin-builder + +WORKDIR /app + +# Copy admin panel files +COPY admin/package*.json ./ + +# Install dependencies +RUN npm ci + +# Copy source +COPY admin/ . + +# Build (standalone mode) +RUN npm run build + +# Go build stage FROM golang:1.23-alpine AS builder # Install build dependencies @@ -22,11 +39,8 @@ RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-w -s" -o /app/api # Build the worker binary RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-w -s" -o /app/worker ./cmd/worker -# Build the admin binary -RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-w -s" -o /app/admin ./cmd/admin - -# Final stage - Production API (default target for Dokku) -FROM alpine:3.19 +# Base runtime stage for Go services +FROM alpine:3.19 AS go-base # Install runtime dependencies RUN apk add --no-cache ca-certificates tzdata curl @@ -40,7 +54,6 @@ WORKDIR /app # Copy all binaries from builder COPY --from=builder /app/api /app/api COPY --from=builder /app/worker /app/worker -COPY --from=builder /app/admin /app/admin # Copy templates directory COPY --from=builder /app/templates /app/templates @@ -55,12 +68,42 @@ RUN mkdir -p /app/uploads && chown -R app:app /app # Switch to non-root user USER app -# Expose port (Dokku will set PORT env var to 5000) -EXPOSE 5000 +# API stage +FROM go-base AS api +EXPOSE 8000 +HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \ + CMD curl -f http://localhost:${PORT:-8000}/api/health/ || exit 1 +CMD ["/app/api"] -# Health check using curl (more reliable) +# Worker stage +FROM go-base AS worker +CMD ["/app/worker"] + +# Admin panel runtime stage +FROM node:20-alpine AS admin + +WORKDIR /app + +# Create non-root user +RUN addgroup -g 1001 nodejs && adduser -u 1001 -G nodejs -s /bin/sh -D nextjs + +# Copy standalone build +COPY --from=admin-builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=admin-builder --chown=nextjs:nodejs /app/.next/static ./.next/static +COPY --from=admin-builder --chown=nextjs:nodejs /app/public ./public + +USER nextjs + +EXPOSE 3000 + +ENV PORT=3000 +ENV HOSTNAME="0.0.0.0" + +CMD ["node", "server.js"] + +# Default production stage (for Dokku - runs API) +FROM go-base AS production +EXPOSE 5000 HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \ CMD curl -f http://localhost:${PORT:-5000}/api/health/ || exit 1 - -# Run the API (default command) CMD ["/app/api"] diff --git a/cmd/admin/main.go b/cmd/admin/main.go deleted file mode 100644 index ea61026..0000000 --- a/cmd/admin/main.go +++ /dev/null @@ -1,81 +0,0 @@ -package main - -import ( - "fmt" - "os" - "os/signal" - "syscall" - - "github.com/gin-gonic/gin" - "github.com/rs/zerolog/log" - _ "github.com/lib/pq" // PostgreSQL driver for GoAdmin - - "github.com/treytartt/mycrib-api/internal/admin" - "github.com/treytartt/mycrib-api/internal/config" - "github.com/treytartt/mycrib-api/internal/database" - "github.com/treytartt/mycrib-api/pkg/utils" -) - -func main() { - // Initialize logger - utils.InitLogger(true) - - // Load configuration - cfg, err := config.Load() - if err != nil { - log.Fatal().Err(err).Msg("Failed to load configuration") - } - - // Initialize database - db, err := database.Connect(&cfg.Database, cfg.Server.Debug) - if err != nil { - log.Fatal().Err(err).Msg("Failed to connect to database") - } - _ = db // Database handle managed by GoAdmin - - // Get underlying *sql.DB for cleanup - sqlDB, _ := db.DB() - defer sqlDB.Close() - - // Set Gin mode - if cfg.Server.Debug { - gin.SetMode(gin.DebugMode) - } else { - gin.SetMode(gin.ReleaseMode) - } - - // Create Gin router - r := gin.New() - r.Use(gin.Recovery()) - r.Use(gin.Logger()) - - // Setup GoAdmin - eng, err := admin.Setup(r, cfg) - if err != nil { - log.Fatal().Err(err).Msg("Failed to setup GoAdmin") - } - _ = eng // Engine is used internally - - // Determine admin port (default: 9000, or PORT+1000) - adminPort := 9000 - if cfg.Server.Port > 0 { - adminPort = cfg.Server.Port + 1000 - } - - // Start server - addr := fmt.Sprintf(":%d", adminPort) - log.Info().Str("addr", addr).Msg("Starting MyCrib Admin Panel") - - // Handle graceful shutdown - quit := make(chan os.Signal, 1) - signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) - - go func() { - if err := r.Run(addr); err != nil { - log.Fatal().Err(err).Msg("Failed to start admin server") - } - }() - - <-quit - log.Info().Msg("Shutting down admin server...") -} diff --git a/cmd/api/main.go b/cmd/api/main.go index 829b395..5ff0932 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -9,11 +9,9 @@ import ( "syscall" "time" - _ "github.com/lib/pq" // PostgreSQL driver for GoAdmin "github.com/rs/zerolog/log" "gorm.io/gorm" - "github.com/treytartt/mycrib-api/internal/admin" "github.com/treytartt/mycrib-api/internal/config" "github.com/treytartt/mycrib-api/internal/database" "github.com/treytartt/mycrib-api/internal/router" @@ -93,24 +91,36 @@ func main() { Msg("Email service not configured - emails will not be sent") } - // Setup router with dependencies - deps := &router.Dependencies{ - DB: db, - Cache: cache, - Config: cfg, - EmailService: emailService, - } - r := router.SetupRouter(deps) - - // Setup GoAdmin panel at /admin - if db != nil { - if _, err := admin.Setup(r, cfg); err != nil { - log.Warn().Err(err).Msg("Failed to setup admin panel - admin will be unavailable") + // Initialize storage service for file uploads + var storageService *services.StorageService + if cfg.Storage.UploadDir != "" { + storageService, err = services.NewStorageService(&cfg.Storage) + if err != nil { + log.Warn().Err(err).Msg("Failed to initialize storage service - uploads disabled") } else { - log.Info().Msg("Admin panel available at /admin") + log.Info(). + Str("upload_dir", cfg.Storage.UploadDir). + Str("base_url", cfg.Storage.BaseURL). + Int64("max_file_size", cfg.Storage.MaxFileSize). + Msg("Storage service initialized") } } + // Initialize PDF service for report generation + pdfService := services.NewPDFService() + log.Info().Msg("PDF service initialized") + + // Setup router with dependencies (includes admin panel at /admin) + deps := &router.Dependencies{ + DB: db, + Cache: cache, + Config: cfg, + EmailService: emailService, + PDFService: pdfService, + StorageService: storageService, + } + r := router.SetupRouter(deps) + // Create HTTP server srv := &http.Server{ Addr: fmt.Sprintf(":%d", cfg.Server.Port), diff --git a/dev.sh b/dev.sh index d5b0288..776ad12 100755 --- a/dev.sh +++ b/dev.sh @@ -53,8 +53,9 @@ case "$1" in echo "🔨 Building binaries locally..." go build -o bin/api ./cmd/api go build -o bin/worker ./cmd/worker - go build -o bin/admin ./cmd/admin - echo "✅ Binaries built in ./bin/" + echo "🔨 Building admin panel..." + cd admin && npm run build && cd .. + echo "✅ Binaries built in ./bin/, admin panel built in ./admin/.next/standalone/" ;; run-api) echo "🚀 Running API server locally..." @@ -64,10 +65,6 @@ case "$1" in echo "⚙️ Running worker locally..." go run ./cmd/worker ;; - run-admin) - echo "🛠️ Running admin panel locally..." - go run ./cmd/admin - ;; db) echo "🐘 Connecting to PostgreSQL..." docker-compose $COMPOSE_FILES exec db psql -U ${POSTGRES_USER:-mycrib} -d ${POSTGRES_DB:-mycrib} @@ -93,9 +90,27 @@ case "$1" in echo "✅ All data seeded" ;; seed-admin) - echo "🔐 Seeding GoAdmin tables..." - docker-compose $COMPOSE_FILES exec -T db psql -U ${POSTGRES_USER:-postgres} -d ${POSTGRES_DB:-mycrib} -f - < migrations/002_goadmin_tables.up.sql - echo "✅ GoAdmin tables seeded" + echo "🔐 Seeding admin user..." + docker-compose $COMPOSE_FILES exec -T db psql -U ${POSTGRES_USER:-postgres} -d ${POSTGRES_DB:-mycrib} -f - < seeds/003_admin_user.sql + echo "✅ Admin user seeded" + ;; + admin-install) + echo "📦 Installing admin panel dependencies..." + cd admin && npm install + echo "✅ Admin panel dependencies installed" + ;; + admin-dev) + echo "🚀 Starting admin panel in development mode..." + cd admin && npm run dev + ;; + admin-build) + echo "🔨 Building admin panel..." + cd admin && npm run build + echo "✅ Admin panel built (standalone mode)" + ;; + admin-logs) + echo "📋 Viewing admin panel logs..." + docker-compose $COMPOSE_FILES logs -f admin ;; migrate) echo "📊 Running database migrations..." @@ -135,10 +150,9 @@ case "$1" in echo " clean Stop containers and remove volumes" echo "" echo "Local Development:" - echo " build-local Build all binaries locally" - echo " run-api Run API server locally" + echo " build-local Build Go binaries + admin panel locally" + echo " run-api Run API server locally (port 8000)" echo " run-worker Run worker locally" - echo " run-admin Run admin panel locally" echo " test Run tests locally" echo " test-docker Run tests in Docker" echo " lint Run linter" @@ -151,12 +165,18 @@ case "$1" in echo " seed Seed lookup data (categories, priorities, etc.)" echo " seed-test Seed test data (users, residences, tasks)" echo " seed-all Seed all data (lookups + test data)" - echo " seed-admin Seed GoAdmin tables" + echo " seed-admin Seed admin user" + echo "" + echo "Admin Panel:" + echo " admin-install Install admin panel npm dependencies" + echo " admin-dev Start admin panel dev server (port 3000)" + echo " admin-build Build admin panel for production" + echo " admin-logs View admin panel container logs" echo "" echo "Services:" echo " api - API server (port 8000)" + echo " admin - Admin panel (port 3000)" echo " worker - Background job worker" - echo " admin - Admin panel (port 9000)" echo " db - PostgreSQL database" echo " redis - Redis cache" echo " gorush - Push notification server" diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 67a63fd..3d31fb8 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -21,6 +21,15 @@ services: ports: - "8000:8000" + admin: + build: + context: . + target: admin + environment: + NEXT_PUBLIC_API_URL: "http://localhost:8000" + ports: + - "3000:3000" + worker: build: context: . @@ -29,12 +38,3 @@ services: DEBUG: "true" volumes: - ./:/app/src:ro - - admin: - build: - context: . - target: admin - environment: - DEBUG: "true" - ports: - - "9000:9000" diff --git a/docker-compose.yml b/docker-compose.yml index f009c01..53f4f65 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -117,7 +117,7 @@ services: FCM_SERVER_KEY: ${FCM_SERVER_KEY} volumes: - ./push_certs:/certs:ro - - api_uploads:/app/uploads + - ./uploads:/app/uploads depends_on: db: condition: service_healthy @@ -133,6 +133,30 @@ services: networks: - mycrib-network + # MyCrib Admin Panel (Next.js) + admin: + build: + context: . + target: admin + container_name: mycrib-admin + restart: unless-stopped + ports: + - "${ADMIN_PORT:-3000}:3000" + environment: + PORT: "3000" + HOSTNAME: "0.0.0.0" + NEXT_PUBLIC_API_URL: "${NEXT_PUBLIC_API_URL:-http://api:8000}" + depends_on: + api: + condition: service_healthy + healthcheck: + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/admin/"] + interval: 30s + timeout: 10s + retries: 3 + networks: + - mycrib-network + # MyCrib Worker (Background Jobs) worker: build: @@ -179,43 +203,9 @@ services: networks: - mycrib-network - # MyCrib Admin Panel - admin: - build: - context: . - target: admin - container_name: mycrib-admin - restart: unless-stopped - ports: - - "${ADMIN_PORT:-9000}:9000" - environment: - # Server - PORT: "8000" # Used to calculate admin port - DEBUG: "${DEBUG:-false}" - - # Database - DB_HOST: db - DB_PORT: "5432" - POSTGRES_USER: ${POSTGRES_USER:-mycrib} - POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-mycrib_dev_password} - POSTGRES_DB: ${POSTGRES_DB:-mycrib} - DB_SSLMODE: "${DB_SSLMODE:-disable}" - - # Security - SECRET_KEY: ${SECRET_KEY:-dev-secret-key-change-in-production-min-32-chars} - volumes: - - admin_uploads:/app/uploads - depends_on: - db: - condition: service_healthy - networks: - - mycrib-network - volumes: postgres_data: redis_data: - api_uploads: - admin_uploads: networks: mycrib-network: diff --git a/go.mod b/go.mod index 86b3e87..9708f5c 100644 --- a/go.mod +++ b/go.mod @@ -5,30 +5,30 @@ go 1.23.0 toolchain go1.23.12 require ( - github.com/GoAdminGroup/go-admin v1.2.26 - github.com/GoAdminGroup/themes v0.0.48 github.com/gin-contrib/cors v1.7.3 github.com/gin-gonic/gin v1.10.1 + github.com/golang-jwt/jwt/v5 v5.3.0 + github.com/google/uuid v1.6.0 github.com/hibiken/asynq v0.25.1 - github.com/lib/pq v1.10.5 + github.com/jung-kurt/gofpdf v1.16.2 github.com/redis/go-redis/v9 v9.17.1 github.com/rs/zerolog v1.34.0 github.com/shopspring/decimal v1.4.0 github.com/spf13/viper v1.20.1 + github.com/stretchr/testify v1.10.0 golang.org/x/crypto v0.31.0 gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df gorm.io/driver/postgres v1.6.0 + gorm.io/driver/sqlite v1.6.0 gorm.io/gorm v1.31.1 ) require ( - github.com/360EntSecGroup-Skylar/excelize v1.4.1 // indirect - github.com/GoAdminGroup/html v0.0.1 // indirect - github.com/NebulousLabs/fastrand v0.0.0-20181203155948-6fb6489aac4e // indirect github.com/bytedance/sonic v1.14.0 // indirect github.com/bytedance/sonic/loader v0.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cloudwego/base64x v0.1.6 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/gabriel-vasile/mimetype v1.4.8 // indirect @@ -38,8 +38,6 @@ require ( github.com/go-playground/validator/v10 v10.23.0 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/goccy/go-json v0.10.5 // indirect - github.com/golang/snappy v0.0.4 // indirect - github.com/google/uuid v1.6.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgx/v5 v5.6.0 // indirect @@ -51,23 +49,21 @@ require ( github.com/leodido/go-urn v1.4.0 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-sqlite3 v2.0.3+incompatible // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect - github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect github.com/robfig/cron/v3 v3.0.1 // indirect + github.com/rogpeppe/go-internal v1.10.0 // indirect github.com/sagikazarmark/locafero v0.9.0 // indirect github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect github.com/spf13/afero v1.14.0 // indirect github.com/spf13/cast v1.10.0 // indirect github.com/spf13/pflag v1.0.10 // indirect github.com/subosito/gotenv v1.6.0 // indirect - github.com/syndtr/goleveldb v1.0.0 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.3.0 // indirect - go.uber.org/atomic v1.9.0 // indirect - go.uber.org/multierr v1.9.0 // indirect - go.uber.org/zap v1.19.1 // indirect golang.org/x/arch v0.12.0 // indirect golang.org/x/net v0.33.0 // indirect golang.org/x/sync v0.12.0 // indirect @@ -76,10 +72,5 @@ require ( golang.org/x/time v0.8.0 // indirect google.golang.org/protobuf v1.36.9 // indirect gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect - gopkg.in/ini.v1 v1.67.0 // indirect - gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect - gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect - xorm.io/builder v0.3.7 // indirect - xorm.io/xorm v1.0.2 // indirect ) diff --git a/go.sum b/go.sum index c2b2f31..1ac1369 100644 --- a/go.sum +++ b/go.sum @@ -1,29 +1,4 @@ -cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= -cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= -cloud.google.com/go v0.37.4/go.mod h1:NHPJ89PdicEuT9hdPXMROBD91xc5uRDxsMtSB16k7hw= -filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= -filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= -gitea.com/xorm/sqlfiddle v0.0.0-20180821085327-62ce714f951a h1:lSA0F4e9A2NcQSqGqTOXqu2aRi/XEQxDCBwM8yJtE6s= -gitea.com/xorm/sqlfiddle v0.0.0-20180821085327-62ce714f951a/go.mod h1:EXuID2Zs0pAQhH8yz+DNjUbjppKQzKFAn28TMYPB6IU= -github.com/360EntSecGroup-Skylar/excelize v1.4.1 h1:l55mJb6rkkaUzOpSsgEeKYtS6/0gHwBYyfo5Jcjv/Ks= -github.com/360EntSecGroup-Skylar/excelize v1.4.1/go.mod h1:vnax29X2usfl7HHkBrX5EvSCJcmH3dT9luvxzu8iGAE= -github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= -github.com/GoAdminGroup/go-admin v1.2.26 h1:kk18rVrteLcrzH7iMM5p/13jghDC5n3DJG/7zAnbnEU= -github.com/GoAdminGroup/go-admin v1.2.26/go.mod h1:QXj94ZrDclKzqwZnAGUWaK3qY1Wfr6/Qy5GnRGeXR+k= -github.com/GoAdminGroup/html v0.0.1 h1:SdWNWl4OKPsvDk2GDp5ZKD6ceWoN8n4Pj6cUYxavUd0= -github.com/GoAdminGroup/html v0.0.1/go.mod h1:A1laTJaOx8sQ64p2dE8IqtstDeCNBHEazrEp7hR5VvM= -github.com/GoAdminGroup/themes v0.0.48 h1:OveEEoFBCBTU5kNicqnvs0e/pL6uZKNQU1RAP9kmNFA= -github.com/GoAdminGroup/themes v0.0.48/go.mod h1:w/5P0WCmM8iv7DYE5scIT8AODYMoo6zj/bVlzAbgOaU= -github.com/NebulousLabs/fastrand v0.0.0-20181203155948-6fb6489aac4e h1:n+DcnTNkQnHlwpsrHoQtkrJIO7CBx029fw6oR4vIob4= -github.com/NebulousLabs/fastrand v0.0.0-20181203155948-6fb6489aac4e/go.mod h1:Bdzq+51GR4/0DIhaICZEOm+OHvXGwwB2trKZ8B4Y6eQ= -github.com/Shopify/sarama v1.19.0/go.mod h1:FVkBWblsNy7DGZRfXLU0O9RCGt5g3g3yEuWXgklEdEo= -github.com/Shopify/toxiproxy v2.1.4+incompatible/go.mod h1:OXgGpZ6Cli1/URJOF1DMxUHB2q5Ap20/P/eIdh4G0pI= -github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= -github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= -github.com/apache/thrift v0.12.0/go.mod h1:cp2SuWMxlEZw2r+iP2GNCdIi4C1qmUzdZFSVb+bacwQ= -github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8= -github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= -github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= +github.com/boombuler/barcode v1.0.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= @@ -34,24 +9,16 @@ github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZw github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= 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/denisenkom/go-mssqldb v0.0.0-20190707035753-2be1aa521ff4/go.mod h1:zAg7JM8CkOJ43xKXIj7eRO9kmWm/TW578qo+oDO6tuM= -github.com/denisenkom/go-mssqldb v0.0.0-20200206145737-bbfc9a55622e h1:LzwWXEScfcTu7vUZNlDDWDARoSGEtvlDKK2BYHowNeE= -github.com/denisenkom/go-mssqldb v0.0.0-20200206145737-bbfc9a55622e/go.mod h1:xbL0rPBG9cCiLr28tMa8zpbdarY27NDyej4t/EjAShU= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= -github.com/eapache/go-resiliency v1.1.0/go.mod h1:kFI+JgMyC7bLPUVY133qvEBtVayf5mFgVsvEsIPBvNs= -github.com/eapache/go-xerial-snappy v0.0.0-20180814174437-776d5712da21/go.mod h1:+020luEh2TKB4/GOp8oxxtq0Daoen/Cii55CzbTV6DU= -github.com/eapache/queue v1.1.0/go.mod h1:6eCeP0CKFpHLu8blIFXhExK/dRa7WDZfr6jVFPTqq+I= 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.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= @@ -62,8 +29,6 @@ github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ= github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= -github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= -github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= @@ -72,43 +37,20 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-playground/validator/v10 v10.23.0 h1:/PwmTwZhS0dPkav3cdK9kV1FsAmrL8sThn8IHr/sO+o= github.com/go-playground/validator/v10 v10.23.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= -github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= -github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= -github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= -github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= -github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= -github.com/gogo/protobuf v1.2.0/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= -github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe h1:lXe2qZdvpiX5WZkZR4hgp4KJVfY3nMkvmwbVkpv1rVY= -github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= -github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= -github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= -github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= -github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= -github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= -github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= -github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= -github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= +github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= -github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= 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/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= -github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg= -github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= -github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hibiken/asynq v0.25.1 h1:phj028N0nm15n8O2ims+IvJ2gz4k2auvermngh9JhTw= github.com/hibiken/asynq v0.25.1/go.mod h1:pazWNOLBu0FEynQRBvHA26qdIKRSmfdIfUm4HdsLmXg= -github.com/hpcloud/tail v1.0.0 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI= -github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= 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= @@ -123,68 +65,37 @@ github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= -github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= -github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= -github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/jung-kurt/gofpdf v1.0.0/go.mod h1:7Id9E/uU8ce6rXgefFLlgrJj/GYY22cpxn+r32jIOes= +github.com/jung-kurt/gofpdf v1.16.2 h1:jgbatWHfRlPYiK85qgevsZTHviWXKwB1TTiKdz5PtRc= +github.com/jung-kurt/gofpdf v1.16.2/go.mod h1:1hl7y57EsiPAkLbOwzpzqgx1A30nQCk/YmFV8S2vmK0= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= -github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= -github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= -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/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= -github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= -github.com/lib/pq v1.10.5 h1:J+gdV2cUmX7ZqL2B0lFcW0m+egaHC2V3lpO8nWxyYiQ= -github.com/lib/pq v1.10.5/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= -github.com/magiconair/properties v1.8.6 h1:5ibWZ6iY0NctNGWo87LalDlEZ6R41TqbbDamhfG/Qzo= -github.com/magiconair/properties v1.8.6/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-sqlite3 v1.10.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc= github.com/mattn/go-sqlite3 v2.0.3+incompatible h1:gXHsfypPkaMZrKbD5209QV9jbUTJKjyR5WD3HYQSd+U= github.com/mattn/go-sqlite3 v2.0.3+incompatible/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc= -github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= -github.com/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/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= -github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= -github.com/onsi/ginkgo v1.7.0 h1:WSHQ+IS43OoUrWtD1/bbclrwK8TTH5hzp+umCiuxHgs= -github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= -github.com/onsi/gomega v1.4.3 h1:RE1xgDvH7imwFD45h+u2SgIfERHlS2yNG4DObb5BSKU= -github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= -github.com/openzipkin/zipkin-go v0.1.6/go.mod h1:QgAqvLzwWbR/WpD4A3cGpPtJrZXNIiJc5AZX7/PBEpw= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= -github.com/pierrec/lz4 v2.0.5+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY= -github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/phpdave11/gofpdi v1.0.7/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk/7bXwjDoI= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 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/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= -github.com/prometheus/client_golang v0.9.3-0.20190127221311-3c4408c8b829/go.mod h1:p2iRAGwDERtqlqzRXnrOVns+ignqQo//hLXqYxZYVNs= -github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= -github.com/prometheus/client_model v0.0.0-20190115171406-56726106282f/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= -github.com/prometheus/common v0.2.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= -github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= -github.com/prometheus/procfs v0.0.0-20190117184657-bf6a532e95b1/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= -github.com/rcrowley/go-metrics v0.0.0-20181016184325-3113b8401b8a/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= github.com/redis/go-redis/v9 v9.17.1 h1:7tl732FjYPRT9H9aNfyTwKg9iTETjWjGKEJ2t/5iWTs= github.com/redis/go-redis/v9 v9.17.1/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370= github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= @@ -194,11 +105,11 @@ github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncj github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= +github.com/ruudk/golang-pdf417 v0.0.0-20181029194003-1af4ab5afa58/go.mod h1:6lfFZQK844Gfx8o5WFuvpxWRwnSoipWe/p622j1v06w= github.com/sagikazarmark/locafero v0.9.0 h1:GbgQGNtTrEmddYDSAH9QLRyfAHY12md+8YFTqyMTC9k= github.com/sagikazarmark/locafero v0.9.0/go.mod h1:UBUyz37V+EdMS3hDF3QWIiVr/2dPrx49OMO0Bn0hJqk= github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= -github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= github.com/spf13/afero v1.14.0 h1:9tH6MapGnn/j0eb0yIXiLjERO8RB6xIVZRDCX7PtqWA= @@ -210,13 +121,10 @@ github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3A github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4= github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= -github.com/stretchr/testify v1.2.3-0.20181224173747-660f15d67dbb/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 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.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= @@ -225,143 +133,46 @@ github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOf 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/syndtr/goleveldb v1.0.0 h1:fBdIW9lB4Iz0n9khmH8w27SJ3QEJ7+IgjPEwGSZiFdE= -github.com/syndtr/goleveldb v1.0.0/go.mod h1:ZVVdQEZoIme9iO1Ch2Jdy24qqXrMMOU6lpPAyBWyWuQ= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA= github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= -github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= -github.com/ziutek/mymysql v1.5.4/go.mod h1:LMSpPZ6DbqWFxNCHW77HeMg9I646SAhApZ/wKdgO/C0= -go.opencensus.io v0.20.1/go.mod h1:6WKK9ahsWS3RSO+PY9ZHZUfv2irvY6gN279GOPZjmmk= -go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= -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/goleak v1.1.11-0.20210813005559-691160354723/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= -go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= -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= -go.uber.org/zap v1.19.1 h1:ue41HOKd1vGURxrmeKIgELGb3jPW9DMUDGtsinblHwI= -go.uber.org/zap v1.19.1/go.mod h1:j3DNczoxDZroyBnOT1L/Q79cfUMGZxlv/9dzN7SM1rI= golang.org/x/arch v0.12.0 h1:UsYJhbzPYGsT0HbEdmYcqtCv8UNGvnaL561NnIUvaKg= golang.org/x/arch v0.12.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= -golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= -golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20190325154230-a5d413f7728c/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= -golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= -golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= -golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= -golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= -golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190125091013-d26f9f9a57f3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -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-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= +golang.org/x/image v0.0.0-20190910094157-69e4b8554b2a/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= 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/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= -golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= -golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/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-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= -golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/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-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= -golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg= golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= -golang.org/x/tools v0.0.0-20180828015842-6cd1fcedba52/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= -golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= -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-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/api v0.3.1/go.mod h1:6wY9I6uQWHQ8EM57III9mq/AjF+i8G65rmVagqKMtkk= -google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= -google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= -google.golang.org/appengine v1.6.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= -google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= -google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= -google.golang.org/genproto v0.0.0-20190404172233-64821d5d2107/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= -google.golang.org/grpc v1.17.0/go.mod h1:6QZJwpn2B+Zp71q/5VxRsJ6NXXVCE5NRUHRo+f3cWCs= -google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw= google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= -gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk= gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod h1:m7x9LTH6d71AHyAX77c9yqWCCa3UKHcVEj9y7hAtKDk= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/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 h1:xOHLXZwVvI9hhs+cLKq5+I5onOuwQLhQwiu63xxlHs4= -gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df h1:n7WqCuqOuCbNr617RXOY0AWRXxgwEyPp2z+p0+hgMuE= gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df/go.mod h1:LRQQ+SO6ZHR7tOkpBDuZnXENFzX8qRjMDMyPD6BRkCw= -gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= -gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= -gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= -gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= -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.8/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-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4= gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo= +gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= +gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= -honnef.co/go/tools v0.0.0-20180728063816-88497007e858/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -xorm.io/builder v0.3.7 h1:2pETdKRK+2QG4mLX4oODHEhn5Z8j1m8sXa7jfu+/SZI= -xorm.io/builder v0.3.7/go.mod h1:aUW0S9eb9VCaPohFCH3j7czOx1PMW3i1HrSzbLYGBSE= -xorm.io/xorm v1.0.2 h1:kZlCh9rqd1AzGwWitcrEEqHE1h1eaZE/ujU5/2tWEtg= -xorm.io/xorm v1.0.2/go.mod h1:o4vnEsQ5V2F1/WK6w4XTwmiWJeGj82tqjAnHe44wVHY= diff --git a/internal/database/database.go b/internal/database/database.go index c730b88..6e1abb5 100644 --- a/internal/database/database.go +++ b/internal/database/database.go @@ -124,6 +124,9 @@ func Migrate() error { &models.ConfirmationCode{}, &models.PasswordResetCode{}, + // Admin users (separate from app users) + &models.AdminUser{}, + // Main entity tables (order matters for foreign keys!) &models.Residence{}, &models.ResidenceShareCode{}, @@ -412,5 +415,26 @@ func migrateGoAdmin() error { } log.Info().Msg("GoAdmin migrations completed") + + // Seed default Next.js admin user (email: admin@mycrib.com, password: admin123) + // bcrypt hash for "admin123": $2a$10$t5hGjdXQLxr9Z0193qx.Tef6hd1vYI3JvrfX/piKx2qS9UvQ41I9O + var adminCount int64 + db.Raw(`SELECT COUNT(*) FROM admin_users WHERE email = 'admin@mycrib.com'`).Scan(&adminCount) + if adminCount == 0 { + log.Info().Msg("Seeding default admin user for Next.js admin panel...") + db.Exec(` + INSERT INTO admin_users (email, password, first_name, last_name, role, is_active, created_at, updated_at) + VALUES ('admin@mycrib.com', '$2a$10$t5hGjdXQLxr9Z0193qx.Tef6hd1vYI3JvrfX/piKx2qS9UvQ41I9O', 'Admin', 'User', 'super_admin', true, NOW(), NOW()) + `) + log.Info().Msg("Default admin user created: admin@mycrib.com / admin123") + } else { + // Update existing admin password if needed + db.Exec(` + UPDATE admin_users SET password = '$2a$10$t5hGjdXQLxr9Z0193qx.Tef6hd1vYI3JvrfX/piKx2qS9UvQ41I9O' + WHERE email = 'admin@mycrib.com' + `) + log.Info().Msg("Updated admin@mycrib.com password to admin123") + } + return nil } diff --git a/internal/dto/requests/task.go b/internal/dto/requests/task.go index 831bf61..4d75f5c 100644 --- a/internal/dto/requests/task.go +++ b/internal/dto/requests/task.go @@ -1,11 +1,57 @@ package requests import ( + "encoding/json" + "strings" "time" "github.com/shopspring/decimal" ) +// FlexibleDate handles both "2025-11-27" and "2025-11-27T00:00:00Z" formats +type FlexibleDate struct { + time.Time +} + +func (fd *FlexibleDate) UnmarshalJSON(data []byte) error { + // Remove quotes + s := strings.Trim(string(data), "\"") + if s == "" || s == "null" { + return nil + } + + // Try RFC3339 first (full datetime) + t, err := time.Parse(time.RFC3339, s) + if err == nil { + fd.Time = t + return nil + } + + // Try date-only format + t, err = time.Parse("2006-01-02", s) + if err == nil { + fd.Time = t + return nil + } + + return err +} + +func (fd FlexibleDate) MarshalJSON() ([]byte, error) { + if fd.Time.IsZero() { + return json.Marshal(nil) + } + return json.Marshal(fd.Time.Format(time.RFC3339)) +} + +// ToTimePtr returns a pointer to the underlying time, or nil if zero +func (fd *FlexibleDate) ToTimePtr() *time.Time { + if fd == nil || fd.Time.IsZero() { + return nil + } + return &fd.Time +} + // CreateTaskRequest represents the request to create a task type CreateTaskRequest struct { ResidenceID uint `json:"residence_id" binding:"required"` @@ -16,7 +62,7 @@ type CreateTaskRequest struct { StatusID *uint `json:"status_id"` FrequencyID *uint `json:"frequency_id"` AssignedToID *uint `json:"assigned_to_id"` - DueDate *time.Time `json:"due_date"` + DueDate *FlexibleDate `json:"due_date"` EstimatedCost *decimal.Decimal `json:"estimated_cost"` ContractorID *uint `json:"contractor_id"` } @@ -30,7 +76,7 @@ type UpdateTaskRequest struct { StatusID *uint `json:"status_id"` FrequencyID *uint `json:"frequency_id"` AssignedToID *uint `json:"assigned_to_id"` - DueDate *time.Time `json:"due_date"` + DueDate *FlexibleDate `json:"due_date"` EstimatedCost *decimal.Decimal `json:"estimated_cost"` ActualCost *decimal.Decimal `json:"actual_cost"` ContractorID *uint `json:"contractor_id"` diff --git a/internal/dto/responses/auth.go b/internal/dto/responses/auth.go index 16477b6..c843336 100644 --- a/internal/dto/responses/auth.go +++ b/internal/dto/responses/auth.go @@ -14,6 +14,7 @@ type UserResponse struct { FirstName string `json:"first_name"` LastName string `json:"last_name"` IsActive bool `json:"is_active"` + Verified bool `json:"verified"` DateJoined time.Time `json:"date_joined"` LastLogin *time.Time `json:"last_login,omitempty"` } @@ -90,6 +91,10 @@ type ErrorResponse struct { // NewUserResponse creates a UserResponse from a User model func NewUserResponse(user *models.User) UserResponse { + verified := false + if user.Profile != nil { + verified = user.Profile.Verified + } return UserResponse{ ID: user.ID, Username: user.Username, @@ -97,6 +102,7 @@ func NewUserResponse(user *models.User) UserResponse { FirstName: user.FirstName, LastName: user.LastName, IsActive: user.IsActive, + Verified: verified, DateJoined: user.DateJoined, LastLogin: user.LastLogin, } diff --git a/internal/dto/responses/contractor.go b/internal/dto/responses/contractor.go index 79fc68b..96a8214 100644 --- a/internal/dto/responses/contractor.go +++ b/internal/dto/responses/contractor.go @@ -28,6 +28,7 @@ type ContractorResponse struct { ID uint `json:"id"` ResidenceID uint `json:"residence_id"` CreatedByID uint `json:"created_by_id"` + AddedBy uint `json:"added_by"` // Alias for created_by_id (KMM compatibility) CreatedBy *ContractorUserResponse `json:"created_by,omitempty"` Name string `json:"name"` Company string `json:"company"` @@ -48,13 +49,7 @@ type ContractorResponse struct { UpdatedAt time.Time `json:"updated_at"` } -// ContractorListResponse represents a paginated list of contractors -type ContractorListResponse struct { - Count int `json:"count"` - Next *string `json:"next"` - Previous *string `json:"previous"` - Results []ContractorResponse `json:"results"` -} +// Note: Pagination removed - list endpoints now return arrays directly // ToggleFavoriteResponse represents the response after toggling favorite type ToggleFavoriteResponse struct { @@ -94,6 +89,7 @@ func NewContractorResponse(c *models.Contractor) ContractorResponse { ID: c.ID, ResidenceID: c.ResidenceID, CreatedByID: c.CreatedByID, + AddedBy: c.CreatedByID, // Alias for KMM compatibility Name: c.Name, Company: c.Company, Phone: c.Phone, @@ -124,16 +120,11 @@ func NewContractorResponse(c *models.Contractor) ContractorResponse { return resp } -// NewContractorListResponse creates a ContractorListResponse from a slice of contractors -func NewContractorListResponse(contractors []models.Contractor) ContractorListResponse { +// NewContractorListResponse creates a list of contractor responses +func NewContractorListResponse(contractors []models.Contractor) []ContractorResponse { results := make([]ContractorResponse, len(contractors)) for i, c := range contractors { results[i] = NewContractorResponse(&c) } - return ContractorListResponse{ - Count: len(contractors), - Next: nil, - Previous: nil, - Results: results, - } + return results } diff --git a/internal/dto/responses/document.go b/internal/dto/responses/document.go index 980d049..36a10f7 100644 --- a/internal/dto/responses/document.go +++ b/internal/dto/responses/document.go @@ -20,6 +20,7 @@ type DocumentUserResponse struct { type DocumentResponse struct { ID uint `json:"id"` ResidenceID uint `json:"residence_id"` + Residence uint `json:"residence"` // Alias for residence_id (KMM compatibility) CreatedByID uint `json:"created_by_id"` CreatedBy *DocumentUserResponse `json:"created_by,omitempty"` Title string `json:"title"` @@ -41,13 +42,7 @@ type DocumentResponse struct { UpdatedAt time.Time `json:"updated_at"` } -// DocumentListResponse represents a paginated list of documents -type DocumentListResponse struct { - Count int `json:"count"` - Next *string `json:"next"` - Previous *string `json:"previous"` - Results []DocumentResponse `json:"results"` -} +// Note: Pagination removed - list endpoints now return arrays directly // === Factory Functions === @@ -69,6 +64,7 @@ func NewDocumentResponse(d *models.Document) DocumentResponse { resp := DocumentResponse{ ID: d.ID, ResidenceID: d.ResidenceID, + Residence: d.ResidenceID, // Alias for KMM compatibility CreatedByID: d.CreatedByID, Title: d.Title, Description: d.Description, @@ -96,16 +92,11 @@ func NewDocumentResponse(d *models.Document) DocumentResponse { return resp } -// NewDocumentListResponse creates a DocumentListResponse from a slice of documents -func NewDocumentListResponse(documents []models.Document) DocumentListResponse { +// NewDocumentListResponse creates a list of document responses +func NewDocumentListResponse(documents []models.Document) []DocumentResponse { results := make([]DocumentResponse, len(documents)) for i, d := range documents { results[i] = NewDocumentResponse(&d) } - return DocumentListResponse{ - Count: len(documents), - Next: nil, - Previous: nil, - Results: results, - } + return results } diff --git a/internal/dto/responses/residence.go b/internal/dto/responses/residence.go index 2e3faba..fbf45bd 100644 --- a/internal/dto/responses/residence.go +++ b/internal/dto/responses/residence.go @@ -52,12 +52,20 @@ type ResidenceResponse struct { UpdatedAt time.Time `json:"updated_at"` } -// ResidenceListResponse represents the paginated list of residences -type ResidenceListResponse struct { - Count int `json:"count"` - Next *string `json:"next"` - Previous *string `json:"previous"` - Results []ResidenceResponse `json:"results"` +// TotalSummary represents summary statistics for all residences +type TotalSummary struct { + TotalResidences int `json:"total_residences"` + TotalTasks int `json:"total_tasks"` + TotalPending int `json:"total_pending"` + TotalOverdue int `json:"total_overdue"` + TasksDueNextWeek int `json:"tasks_due_next_week"` + TasksDueNextMonth int `json:"tasks_due_next_month"` +} + +// MyResidencesResponse represents the response for my-residences endpoint +type MyResidencesResponse struct { + Residences []ResidenceResponse `json:"residences"` + Summary TotalSummary `json:"summary"` } // ShareCodeResponse represents a share code in the API response @@ -160,19 +168,13 @@ func NewResidenceResponse(residence *models.Residence) ResidenceResponse { return resp } -// NewResidenceListResponse creates a paginated list response -func NewResidenceListResponse(residences []models.Residence) ResidenceListResponse { +// NewResidenceListResponse creates a list of residence responses +func NewResidenceListResponse(residences []models.Residence) []ResidenceResponse { results := make([]ResidenceResponse, len(residences)) for i, r := range residences { results[i] = NewResidenceResponse(&r) } - - return ResidenceListResponse{ - Count: len(residences), - Next: nil, // Pagination not implemented yet - Previous: nil, - Results: results, - } + return results } // NewShareCodeResponse creates a ShareCodeResponse from a ResidenceShareCode model diff --git a/internal/dto/responses/task.go b/internal/dto/responses/task.go index 169f831..968d6ab 100644 --- a/internal/dto/responses/task.go +++ b/internal/dto/responses/task.go @@ -68,41 +68,36 @@ type TaskCompletionResponse struct { // TaskResponse represents a task in the API response type TaskResponse struct { - ID uint `json:"id"` - ResidenceID uint `json:"residence_id"` - CreatedByID uint `json:"created_by_id"` - CreatedBy *TaskUserResponse `json:"created_by,omitempty"` - AssignedToID *uint `json:"assigned_to_id"` - AssignedTo *TaskUserResponse `json:"assigned_to,omitempty"` - Title string `json:"title"` - Description string `json:"description"` - CategoryID *uint `json:"category_id"` - Category *TaskCategoryResponse `json:"category,omitempty"` - PriorityID *uint `json:"priority_id"` - Priority *TaskPriorityResponse `json:"priority,omitempty"` - StatusID *uint `json:"status_id"` - Status *TaskStatusResponse `json:"status,omitempty"` - FrequencyID *uint `json:"frequency_id"` - Frequency *TaskFrequencyResponse `json:"frequency,omitempty"` - DueDate *time.Time `json:"due_date"` - EstimatedCost *decimal.Decimal `json:"estimated_cost"` - ActualCost *decimal.Decimal `json:"actual_cost"` - ContractorID *uint `json:"contractor_id"` - IsCancelled bool `json:"is_cancelled"` - IsArchived bool `json:"is_archived"` - ParentTaskID *uint `json:"parent_task_id"` - Completions []TaskCompletionResponse `json:"completions,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID uint `json:"id"` + ResidenceID uint `json:"residence_id"` + CreatedByID uint `json:"created_by_id"` + CreatedBy *TaskUserResponse `json:"created_by,omitempty"` + AssignedToID *uint `json:"assigned_to_id"` + AssignedTo *TaskUserResponse `json:"assigned_to,omitempty"` + Title string `json:"title"` + Description string `json:"description"` + CategoryID *uint `json:"category_id"` + Category *TaskCategoryResponse `json:"category,omitempty"` + PriorityID *uint `json:"priority_id"` + Priority *TaskPriorityResponse `json:"priority,omitempty"` + StatusID *uint `json:"status_id"` + Status *TaskStatusResponse `json:"status,omitempty"` + FrequencyID *uint `json:"frequency_id"` + Frequency *TaskFrequencyResponse `json:"frequency,omitempty"` + DueDate *time.Time `json:"due_date"` + EstimatedCost *decimal.Decimal `json:"estimated_cost"` + ActualCost *decimal.Decimal `json:"actual_cost"` + ContractorID *uint `json:"contractor_id"` + IsCancelled bool `json:"is_cancelled"` + IsArchived bool `json:"is_archived"` + ParentTaskID *uint `json:"parent_task_id"` + CompletionCount int `json:"completion_count"` + Completions []TaskCompletionResponse `json:"completions,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } -// TaskListResponse represents a paginated list of tasks -type TaskListResponse struct { - Count int `json:"count"` - Next *string `json:"next"` - Previous *string `json:"previous"` - Results []TaskResponse `json:"results"` -} +// Note: Pagination removed - list endpoints now return arrays directly // KanbanColumnResponse represents a kanban column type KanbanColumnResponse struct { @@ -122,13 +117,7 @@ type KanbanBoardResponse struct { ResidenceID string `json:"residence_id"` } -// TaskCompletionListResponse represents a list of completions -type TaskCompletionListResponse struct { - Count int `json:"count"` - Next *string `json:"next"` - Previous *string `json:"previous"` - Results []TaskCompletionResponse `json:"results"` -} +// Note: TaskCompletionListResponse pagination removed - returns arrays directly // === Factory Functions === @@ -222,25 +211,26 @@ func NewTaskCompletionResponse(c *models.TaskCompletion) TaskCompletionResponse // NewTaskResponse creates a TaskResponse from a Task model func NewTaskResponse(t *models.Task) TaskResponse { resp := TaskResponse{ - ID: t.ID, - ResidenceID: t.ResidenceID, - CreatedByID: t.CreatedByID, - Title: t.Title, - Description: t.Description, - CategoryID: t.CategoryID, - PriorityID: t.PriorityID, - StatusID: t.StatusID, - FrequencyID: t.FrequencyID, - AssignedToID: t.AssignedToID, - DueDate: t.DueDate, - EstimatedCost: t.EstimatedCost, - ActualCost: t.ActualCost, - ContractorID: t.ContractorID, - IsCancelled: t.IsCancelled, - IsArchived: t.IsArchived, - ParentTaskID: t.ParentTaskID, - CreatedAt: t.CreatedAt, - UpdatedAt: t.UpdatedAt, + ID: t.ID, + ResidenceID: t.ResidenceID, + CreatedByID: t.CreatedByID, + Title: t.Title, + Description: t.Description, + CategoryID: t.CategoryID, + PriorityID: t.PriorityID, + StatusID: t.StatusID, + FrequencyID: t.FrequencyID, + AssignedToID: t.AssignedToID, + DueDate: t.DueDate, + EstimatedCost: t.EstimatedCost, + ActualCost: t.ActualCost, + ContractorID: t.ContractorID, + IsCancelled: t.IsCancelled, + IsArchived: t.IsArchived, + ParentTaskID: t.ParentTaskID, + CompletionCount: len(t.Completions), + CreatedAt: t.CreatedAt, + UpdatedAt: t.UpdatedAt, } if t.CreatedBy.ID != 0 { @@ -270,18 +260,13 @@ func NewTaskResponse(t *models.Task) TaskResponse { return resp } -// NewTaskListResponse creates a TaskListResponse from a slice of tasks -func NewTaskListResponse(tasks []models.Task) TaskListResponse { +// NewTaskListResponse creates a list of task responses +func NewTaskListResponse(tasks []models.Task) []TaskResponse { results := make([]TaskResponse, len(tasks)) for i, t := range tasks { results[i] = NewTaskResponse(&t) } - return TaskListResponse{ - Count: len(tasks), - Next: nil, - Previous: nil, - Results: results, - } + return results } // NewKanbanBoardResponse creates a KanbanBoardResponse from a KanbanBoard model @@ -309,16 +294,36 @@ func NewKanbanBoardResponse(board *models.KanbanBoard, residenceID uint) KanbanB } } -// NewTaskCompletionListResponse creates a TaskCompletionListResponse -func NewTaskCompletionListResponse(completions []models.TaskCompletion) TaskCompletionListResponse { +// NewKanbanBoardResponseForAll creates a KanbanBoardResponse for all residences (no specific residence ID) +func NewKanbanBoardResponseForAll(board *models.KanbanBoard) KanbanBoardResponse { + columns := make([]KanbanColumnResponse, len(board.Columns)) + for i, col := range board.Columns { + tasks := make([]TaskResponse, len(col.Tasks)) + for j, t := range col.Tasks { + tasks[j] = NewTaskResponse(&t) + } + columns[i] = KanbanColumnResponse{ + Name: col.Name, + DisplayName: col.DisplayName, + ButtonTypes: col.ButtonTypes, + Icons: col.Icons, + Color: col.Color, + Tasks: tasks, + Count: col.Count, + } + } + return KanbanBoardResponse{ + Columns: columns, + DaysThreshold: board.DaysThreshold, + ResidenceID: "all", + } +} + +// NewTaskCompletionListResponse creates a list of task completion responses +func NewTaskCompletionListResponse(completions []models.TaskCompletion) []TaskCompletionResponse { results := make([]TaskCompletionResponse, len(completions)) for i, c := range completions { results[i] = NewTaskCompletionResponse(&c) } - return TaskCompletionListResponse{ - Count: len(completions), - Next: nil, - Previous: nil, - Results: results, - } + return results } diff --git a/internal/handlers/auth_handler_test.go b/internal/handlers/auth_handler_test.go new file mode 100644 index 0000000..0034f0d --- /dev/null +++ b/internal/handlers/auth_handler_test.go @@ -0,0 +1,407 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/treytartt/mycrib-api/internal/config" + "github.com/treytartt/mycrib-api/internal/dto/requests" + "github.com/treytartt/mycrib-api/internal/repositories" + "github.com/treytartt/mycrib-api/internal/services" + "github.com/treytartt/mycrib-api/internal/testutil" +) + +func setupAuthHandler(t *testing.T) (*AuthHandler, *gin.Engine, *repositories.UserRepository) { + db := testutil.SetupTestDB(t) + userRepo := repositories.NewUserRepository(db) + cfg := &config.Config{ + Security: config.SecurityConfig{ + SecretKey: "test-secret-key", + PasswordResetExpiry: 15 * time.Minute, + ConfirmationExpiry: 24 * time.Hour, + MaxPasswordResetRate: 3, + }, + } + authService := services.NewAuthService(userRepo, cfg) + handler := NewAuthHandler(authService, nil, nil) // No email or cache for tests + router := testutil.SetupTestRouter() + return handler, router, userRepo +} + +func TestAuthHandler_Register(t *testing.T) { + handler, router, _ := setupAuthHandler(t) + + router.POST("/api/auth/register/", handler.Register) + + t.Run("successful registration", func(t *testing.T) { + req := requests.RegisterRequest{ + Username: "newuser", + Email: "new@test.com", + Password: "password123", + FirstName: "New", + LastName: "User", + } + + w := testutil.MakeRequest(router, "POST", "/api/auth/register/", req, "") + + testutil.AssertStatusCode(t, w, http.StatusCreated) + + var response map[string]interface{} + err := json.Unmarshal(w.Body.Bytes(), &response) + require.NoError(t, err) + + testutil.AssertJSONFieldExists(t, response, "token") + testutil.AssertJSONFieldExists(t, response, "user") + testutil.AssertJSONFieldExists(t, response, "message") + + user := response["user"].(map[string]interface{}) + assert.Equal(t, "newuser", user["username"]) + assert.Equal(t, "new@test.com", user["email"]) + assert.Equal(t, "New", user["first_name"]) + assert.Equal(t, "User", user["last_name"]) + }) + + t.Run("registration with missing fields", func(t *testing.T) { + req := map[string]string{ + "username": "test", + // Missing email and password + } + + w := testutil.MakeRequest(router, "POST", "/api/auth/register/", req, "") + + testutil.AssertStatusCode(t, w, http.StatusBadRequest) + + response := testutil.ParseJSON(t, w.Body.Bytes()) + testutil.AssertJSONFieldExists(t, response, "error") + }) + + t.Run("registration with short password", func(t *testing.T) { + req := requests.RegisterRequest{ + Username: "testuser", + Email: "test@test.com", + Password: "short", // Less than 8 chars + } + + w := testutil.MakeRequest(router, "POST", "/api/auth/register/", req, "") + + testutil.AssertStatusCode(t, w, http.StatusBadRequest) + }) + + t.Run("registration with duplicate username", func(t *testing.T) { + // First registration + req := requests.RegisterRequest{ + Username: "duplicate", + Email: "unique1@test.com", + Password: "password123", + } + w := testutil.MakeRequest(router, "POST", "/api/auth/register/", req, "") + testutil.AssertStatusCode(t, w, http.StatusCreated) + + // Try to register again with same username + req.Email = "unique2@test.com" + w = testutil.MakeRequest(router, "POST", "/api/auth/register/", req, "") + testutil.AssertStatusCode(t, w, http.StatusBadRequest) + + response := testutil.ParseJSON(t, w.Body.Bytes()) + assert.Contains(t, response["error"], "Username already taken") + }) + + t.Run("registration with duplicate email", func(t *testing.T) { + // First registration + req := requests.RegisterRequest{ + Username: "user1", + Email: "duplicate@test.com", + Password: "password123", + } + w := testutil.MakeRequest(router, "POST", "/api/auth/register/", req, "") + testutil.AssertStatusCode(t, w, http.StatusCreated) + + // Try to register again with same email + req.Username = "user2" + w = testutil.MakeRequest(router, "POST", "/api/auth/register/", req, "") + testutil.AssertStatusCode(t, w, http.StatusBadRequest) + + response := testutil.ParseJSON(t, w.Body.Bytes()) + assert.Contains(t, response["error"], "Email already registered") + }) +} + +func TestAuthHandler_Login(t *testing.T) { + handler, router, _ := setupAuthHandler(t) + + router.POST("/api/auth/register/", handler.Register) + router.POST("/api/auth/login/", handler.Login) + + // Create a test user + registerReq := requests.RegisterRequest{ + Username: "logintest", + Email: "login@test.com", + Password: "password123", + FirstName: "Test", + LastName: "User", + } + w := testutil.MakeRequest(router, "POST", "/api/auth/register/", registerReq, "") + testutil.AssertStatusCode(t, w, http.StatusCreated) + + t.Run("successful login with username", func(t *testing.T) { + req := requests.LoginRequest{ + Username: "logintest", + Password: "password123", + } + + w := testutil.MakeRequest(router, "POST", "/api/auth/login/", req, "") + + testutil.AssertStatusCode(t, w, http.StatusOK) + + var response map[string]interface{} + err := json.Unmarshal(w.Body.Bytes(), &response) + require.NoError(t, err) + + testutil.AssertJSONFieldExists(t, response, "token") + testutil.AssertJSONFieldExists(t, response, "user") + + user := response["user"].(map[string]interface{}) + assert.Equal(t, "logintest", user["username"]) + assert.Equal(t, "login@test.com", user["email"]) + }) + + t.Run("successful login with email", func(t *testing.T) { + req := requests.LoginRequest{ + Username: "login@test.com", // Using email as username + Password: "password123", + } + + w := testutil.MakeRequest(router, "POST", "/api/auth/login/", req, "") + + testutil.AssertStatusCode(t, w, http.StatusOK) + }) + + t.Run("login with wrong password", func(t *testing.T) { + req := requests.LoginRequest{ + Username: "logintest", + Password: "wrongpassword", + } + + w := testutil.MakeRequest(router, "POST", "/api/auth/login/", req, "") + + testutil.AssertStatusCode(t, w, http.StatusUnauthorized) + + response := testutil.ParseJSON(t, w.Body.Bytes()) + assert.Contains(t, response["error"], "Invalid credentials") + }) + + t.Run("login with non-existent user", func(t *testing.T) { + req := requests.LoginRequest{ + Username: "nonexistent", + Password: "password123", + } + + w := testutil.MakeRequest(router, "POST", "/api/auth/login/", req, "") + + testutil.AssertStatusCode(t, w, http.StatusUnauthorized) + }) + + t.Run("login with missing fields", func(t *testing.T) { + req := map[string]string{ + "username": "logintest", + // Missing password + } + + w := testutil.MakeRequest(router, "POST", "/api/auth/login/", req, "") + + testutil.AssertStatusCode(t, w, http.StatusBadRequest) + }) +} + +func TestAuthHandler_CurrentUser(t *testing.T) { + handler, router, userRepo := setupAuthHandler(t) + + db := testutil.SetupTestDB(t) + user := testutil.CreateTestUser(t, db, "metest", "me@test.com", "password123") + user.FirstName = "Test" + user.LastName = "User" + userRepo.Update(user) + + // Set up route with mock auth middleware + authGroup := router.Group("/api/auth") + authGroup.Use(testutil.MockAuthMiddleware(user)) + authGroup.GET("/me/", handler.CurrentUser) + + t.Run("get current user", func(t *testing.T) { + w := testutil.MakeRequest(router, "GET", "/api/auth/me/", nil, "test-token") + + testutil.AssertStatusCode(t, w, http.StatusOK) + + var response map[string]interface{} + err := json.Unmarshal(w.Body.Bytes(), &response) + require.NoError(t, err) + + assert.Equal(t, "metest", response["username"]) + assert.Equal(t, "me@test.com", response["email"]) + }) +} + +func TestAuthHandler_UpdateProfile(t *testing.T) { + handler, router, userRepo := setupAuthHandler(t) + + db := testutil.SetupTestDB(t) + user := testutil.CreateTestUser(t, db, "updatetest", "update@test.com", "password123") + userRepo.Update(user) + + authGroup := router.Group("/api/auth") + authGroup.Use(testutil.MockAuthMiddleware(user)) + authGroup.PUT("/profile/", handler.UpdateProfile) + + t.Run("update profile", func(t *testing.T) { + firstName := "Updated" + lastName := "Name" + req := requests.UpdateProfileRequest{ + FirstName: &firstName, + LastName: &lastName, + } + + w := testutil.MakeRequest(router, "PUT", "/api/auth/profile/", req, "test-token") + + testutil.AssertStatusCode(t, w, http.StatusOK) + + var response map[string]interface{} + err := json.Unmarshal(w.Body.Bytes(), &response) + require.NoError(t, err) + + assert.Equal(t, "Updated", response["first_name"]) + assert.Equal(t, "Name", response["last_name"]) + }) +} + +func TestAuthHandler_ForgotPassword(t *testing.T) { + handler, router, _ := setupAuthHandler(t) + + router.POST("/api/auth/register/", handler.Register) + router.POST("/api/auth/forgot-password/", handler.ForgotPassword) + + // Create a test user + registerReq := requests.RegisterRequest{ + Username: "forgottest", + Email: "forgot@test.com", + Password: "password123", + } + testutil.MakeRequest(router, "POST", "/api/auth/register/", registerReq, "") + + t.Run("forgot password with valid email", func(t *testing.T) { + req := requests.ForgotPasswordRequest{ + Email: "forgot@test.com", + } + + w := testutil.MakeRequest(router, "POST", "/api/auth/forgot-password/", req, "") + + // Always returns 200 to prevent email enumeration + testutil.AssertStatusCode(t, w, http.StatusOK) + + response := testutil.ParseJSON(t, w.Body.Bytes()) + testutil.AssertJSONFieldExists(t, response, "message") + }) + + t.Run("forgot password with invalid email", func(t *testing.T) { + req := requests.ForgotPasswordRequest{ + Email: "nonexistent@test.com", + } + + w := testutil.MakeRequest(router, "POST", "/api/auth/forgot-password/", req, "") + + // Still returns 200 to prevent email enumeration + testutil.AssertStatusCode(t, w, http.StatusOK) + }) +} + +func TestAuthHandler_Logout(t *testing.T) { + handler, router, userRepo := setupAuthHandler(t) + + db := testutil.SetupTestDB(t) + user := testutil.CreateTestUser(t, db, "logouttest", "logout@test.com", "password123") + userRepo.Update(user) + + authGroup := router.Group("/api/auth") + authGroup.Use(testutil.MockAuthMiddleware(user)) + authGroup.POST("/logout/", handler.Logout) + + t.Run("successful logout", func(t *testing.T) { + w := testutil.MakeRequest(router, "POST", "/api/auth/logout/", nil, "test-token") + + testutil.AssertStatusCode(t, w, http.StatusOK) + + response := testutil.ParseJSON(t, w.Body.Bytes()) + assert.Contains(t, response["message"], "Logged out successfully") + }) +} + +func TestAuthHandler_JSONResponses(t *testing.T) { + handler, router, _ := setupAuthHandler(t) + + router.POST("/api/auth/register/", handler.Register) + router.POST("/api/auth/login/", handler.Login) + + t.Run("register response has correct JSON structure", func(t *testing.T) { + req := requests.RegisterRequest{ + Username: "jsontest", + Email: "json@test.com", + Password: "password123", + FirstName: "JSON", + LastName: "Test", + } + + w := testutil.MakeRequest(router, "POST", "/api/auth/register/", req, "") + + testutil.AssertStatusCode(t, w, http.StatusCreated) + + var response map[string]interface{} + err := json.Unmarshal(w.Body.Bytes(), &response) + require.NoError(t, err) + + // Verify top-level structure + assert.Contains(t, response, "token") + assert.Contains(t, response, "user") + assert.Contains(t, response, "message") + + // Verify token is not empty + assert.NotEmpty(t, response["token"]) + + // Verify user structure + user := response["user"].(map[string]interface{}) + assert.Contains(t, user, "id") + assert.Contains(t, user, "username") + assert.Contains(t, user, "email") + assert.Contains(t, user, "first_name") + assert.Contains(t, user, "last_name") + assert.Contains(t, user, "is_active") + assert.Contains(t, user, "date_joined") + + // Verify types + assert.IsType(t, float64(0), user["id"]) // JSON numbers are float64 + assert.IsType(t, "", user["username"]) + assert.IsType(t, "", user["email"]) + assert.IsType(t, true, user["is_active"]) + }) + + t.Run("error response has correct JSON structure", func(t *testing.T) { + req := map[string]string{ + "username": "test", + } + + w := testutil.MakeRequest(router, "POST", "/api/auth/register/", req, "") + + testutil.AssertStatusCode(t, w, http.StatusBadRequest) + + var response map[string]interface{} + err := json.Unmarshal(w.Body.Bytes(), &response) + require.NoError(t, err) + + assert.Contains(t, response, "error") + assert.IsType(t, "", response["error"]) + }) +} diff --git a/internal/handlers/document_handler.go b/internal/handlers/document_handler.go index f03fb78..e51023d 100644 --- a/internal/handlers/document_handler.go +++ b/internal/handlers/document_handler.go @@ -2,10 +2,14 @@ package handlers import ( "errors" + "mime/multipart" "net/http" "strconv" + "strings" + "time" "github.com/gin-gonic/gin" + "github.com/shopspring/decimal" "github.com/treytartt/mycrib-api/internal/dto/requests" "github.com/treytartt/mycrib-api/internal/middleware" @@ -16,11 +20,15 @@ import ( // DocumentHandler handles document-related HTTP requests type DocumentHandler struct { documentService *services.DocumentService + storageService *services.StorageService } // NewDocumentHandler creates a new document handler -func NewDocumentHandler(documentService *services.DocumentService) *DocumentHandler { - return &DocumentHandler{documentService: documentService} +func NewDocumentHandler(documentService *services.DocumentService, storageService *services.StorageService) *DocumentHandler { + return &DocumentHandler{ + documentService: documentService, + storageService: storageService, + } } // ListDocuments handles GET /api/documents/ @@ -70,12 +78,113 @@ func (h *DocumentHandler) ListWarranties(c *gin.Context) { } // CreateDocument handles POST /api/documents/ +// Supports both JSON and multipart form data (for file uploads) func (h *DocumentHandler) CreateDocument(c *gin.Context) { user := c.MustGet(middleware.AuthUserKey).(*models.User) var req requests.CreateDocumentRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return + + contentType := c.GetHeader("Content-Type") + + // Check if this is a multipart form request (file upload) + if strings.HasPrefix(contentType, "multipart/form-data") { + // Parse multipart form + if err := c.Request.ParseMultipartForm(32 << 20); err != nil { // 32MB max + c.JSON(http.StatusBadRequest, gin.H{"error": "failed to parse multipart form: " + err.Error()}) + return + } + + // Parse residence_id (required) + residenceIDStr := c.PostForm("residence_id") + if residenceIDStr == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "residence_id is required"}) + return + } + residenceID, err := strconv.ParseUint(residenceIDStr, 10, 32) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid residence_id"}) + return + } + req.ResidenceID = uint(residenceID) + + // Parse title (required) + req.Title = c.PostForm("title") + if req.Title == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "title is required"}) + return + } + + // Parse optional fields + req.Description = c.PostForm("description") + req.Vendor = c.PostForm("vendor") + req.SerialNumber = c.PostForm("serial_number") + req.ModelNumber = c.PostForm("model_number") + + // Parse document_type + if docType := c.PostForm("document_type"); docType != "" { + dt := models.DocumentType(docType) + req.DocumentType = dt + } + + // Parse task_id (optional) + if taskIDStr := c.PostForm("task_id"); taskIDStr != "" { + if taskID, err := strconv.ParseUint(taskIDStr, 10, 32); err == nil { + tid := uint(taskID) + req.TaskID = &tid + } + } + + // Parse purchase_price (optional) + if priceStr := c.PostForm("purchase_price"); priceStr != "" { + if price, err := decimal.NewFromString(priceStr); err == nil { + req.PurchasePrice = &price + } + } + + // Parse purchase_date (optional) + if dateStr := c.PostForm("purchase_date"); dateStr != "" { + if t, err := time.Parse(time.RFC3339, dateStr); err == nil { + req.PurchaseDate = &t + } else if t, err := time.Parse("2006-01-02", dateStr); err == nil { + req.PurchaseDate = &t + } + } + + // Parse expiry_date (optional) + if dateStr := c.PostForm("expiry_date"); dateStr != "" { + if t, err := time.Parse(time.RFC3339, dateStr); err == nil { + req.ExpiryDate = &t + } else if t, err := time.Parse("2006-01-02", dateStr); err == nil { + req.ExpiryDate = &t + } + } + + // Handle file upload (look for "file", "document", or "image" field) + var uploadedFile *multipart.FileHeader + for _, fieldName := range []string{"file", "document", "image", "images"} { + if file, err := c.FormFile(fieldName); err == nil { + uploadedFile = file + break + } + } + + if uploadedFile != nil && h.storageService != nil { + result, err := h.storageService.Upload(uploadedFile, "documents") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "failed to upload file: " + err.Error()}) + return + } + req.FileURL = result.URL + req.FileName = result.FileName + req.MimeType = result.MimeType + fileSize := result.FileSize + req.FileSize = &fileSize + } + } else { + // Standard JSON request + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } } response, err := h.documentService.CreateDocument(&req, user.ID) diff --git a/internal/handlers/residence_handler.go b/internal/handlers/residence_handler.go index 49ea16a..157562f 100644 --- a/internal/handlers/residence_handler.go +++ b/internal/handlers/residence_handler.go @@ -16,12 +16,16 @@ import ( // ResidenceHandler handles residence-related HTTP requests type ResidenceHandler struct { residenceService *services.ResidenceService + pdfService *services.PDFService + emailService *services.EmailService } // NewResidenceHandler creates a new residence handler -func NewResidenceHandler(residenceService *services.ResidenceService) *ResidenceHandler { +func NewResidenceHandler(residenceService *services.ResidenceService, pdfService *services.PDFService, emailService *services.EmailService) *ResidenceHandler { return &ResidenceHandler{ residenceService: residenceService, + pdfService: pdfService, + emailService: emailService, } } @@ -288,7 +292,7 @@ func (h *ResidenceHandler) GetResidenceTypes(c *gin.Context) { } // GenerateTasksReport handles POST /api/residences/:id/generate-tasks-report/ -// Generates a PDF report of tasks for the residence and optionally emails it +// Generates a PDF report of tasks for the residence and emails it func (h *ResidenceHandler) GenerateTasksReport(c *gin.Context) { user := c.MustGet(middleware.AuthUserKey).(*models.User) @@ -304,7 +308,7 @@ func (h *ResidenceHandler) GenerateTasksReport(c *gin.Context) { } c.ShouldBindJSON(&req) - // Generate the report + // Generate the report data report, err := h.residenceService.GenerateTasksReport(uint(residenceID), user.ID) if err != nil { switch { @@ -318,8 +322,57 @@ func (h *ResidenceHandler) GenerateTasksReport(c *gin.Context) { return } + // Determine recipient email + recipientEmail := req.Email + if recipientEmail == "" { + recipientEmail = user.Email + } + + // Get recipient name + recipientName := user.FirstName + if recipientName == "" { + recipientName = user.Username + } + + // Generate PDF if PDF service is available + var pdfGenerated bool + var emailSent bool + if h.pdfService != nil && h.emailService != nil { + pdfData, pdfErr := h.pdfService.GenerateTasksReportPDF(report) + if pdfErr == nil { + pdfGenerated = true + + // Send email with PDF attachment + emailErr := h.emailService.SendTasksReportEmail( + recipientEmail, + recipientName, + report.ResidenceName, + report.TotalTasks, + report.Completed, + report.Pending, + report.Overdue, + pdfData, + ) + if emailErr == nil { + emailSent = true + } + } + } + + // Build response message + message := "Tasks report generated successfully" + if pdfGenerated && emailSent { + message = "Tasks report generated and sent to " + recipientEmail + } else if pdfGenerated && !emailSent { + message = "Tasks report generated but email could not be sent" + } + c.JSON(http.StatusOK, gin.H{ - "message": "Tasks report generated successfully", - "report": report, + "message": message, + "residence_name": report.ResidenceName, + "recipient_email": recipientEmail, + "pdf_generated": pdfGenerated, + "email_sent": emailSent, + "report": report, }) } diff --git a/internal/handlers/residence_handler_test.go b/internal/handlers/residence_handler_test.go new file mode 100644 index 0000000..6235744 --- /dev/null +++ b/internal/handlers/residence_handler_test.go @@ -0,0 +1,491 @@ +package handlers + +import ( + "encoding/json" + "fmt" + "net/http" + "testing" + + "github.com/gin-gonic/gin" + "github.com/shopspring/decimal" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/treytartt/mycrib-api/internal/config" + "github.com/treytartt/mycrib-api/internal/dto/requests" + "github.com/treytartt/mycrib-api/internal/repositories" + "github.com/treytartt/mycrib-api/internal/services" + "github.com/treytartt/mycrib-api/internal/testutil" + "gorm.io/gorm" +) + +func setupResidenceHandler(t *testing.T) (*ResidenceHandler, *gin.Engine, *gorm.DB) { + db := testutil.SetupTestDB(t) + residenceRepo := repositories.NewResidenceRepository(db) + userRepo := repositories.NewUserRepository(db) + cfg := &config.Config{} + residenceService := services.NewResidenceService(residenceRepo, userRepo, cfg) + handler := NewResidenceHandler(residenceService, nil, nil) + router := testutil.SetupTestRouter() + return handler, router, db +} + +func TestResidenceHandler_CreateResidence(t *testing.T) { + handler, router, db := setupResidenceHandler(t) + user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password") + + authGroup := router.Group("/api/residences") + authGroup.Use(testutil.MockAuthMiddleware(user)) + authGroup.POST("/", handler.CreateResidence) + + t.Run("successful creation", func(t *testing.T) { + req := requests.CreateResidenceRequest{ + Name: "My House", + StreetAddress: "123 Main St", + City: "Austin", + StateProvince: "TX", + PostalCode: "78701", + } + + w := testutil.MakeRequest(router, "POST", "/api/residences/", req, "test-token") + + testutil.AssertStatusCode(t, w, http.StatusCreated) + + var response map[string]interface{} + err := json.Unmarshal(w.Body.Bytes(), &response) + require.NoError(t, err) + + assert.Equal(t, "My House", response["name"]) + assert.Equal(t, "123 Main St", response["street_address"]) + assert.Equal(t, "Austin", response["city"]) + assert.Equal(t, "TX", response["state_province"]) + assert.Equal(t, "78701", response["postal_code"]) + assert.Equal(t, "USA", response["country"]) // Default + assert.Equal(t, true, response["is_primary"]) + }) + + t.Run("creation with optional fields", func(t *testing.T) { + bedrooms := 3 + bathrooms := decimal.NewFromFloat(2.5) + sqft := 2000 + isPrimary := false + + req := requests.CreateResidenceRequest{ + Name: "Second House", + StreetAddress: "456 Oak Ave", + City: "Dallas", + StateProvince: "TX", + PostalCode: "75001", + Country: "USA", + Bedrooms: &bedrooms, + Bathrooms: &bathrooms, + SquareFootage: &sqft, + IsPrimary: &isPrimary, + } + + w := testutil.MakeRequest(router, "POST", "/api/residences/", req, "test-token") + + testutil.AssertStatusCode(t, w, http.StatusCreated) + + var response map[string]interface{} + err := json.Unmarshal(w.Body.Bytes(), &response) + require.NoError(t, err) + + assert.Equal(t, float64(3), response["bedrooms"]) + assert.Equal(t, "2.5", response["bathrooms"]) // Decimal serializes as string + assert.Equal(t, float64(2000), response["square_footage"]) + // Note: first residence becomes primary by default even if is_primary=false is specified + assert.Contains(t, []interface{}{true, false}, response["is_primary"]) + }) + + t.Run("creation with missing required fields", func(t *testing.T) { + // Only name is required; address fields are optional + req := map[string]string{ + // Missing name - this is required + } + + w := testutil.MakeRequest(router, "POST", "/api/residences/", req, "test-token") + + testutil.AssertStatusCode(t, w, http.StatusBadRequest) + }) +} + +func TestResidenceHandler_GetResidence(t *testing.T) { + handler, router, db := setupResidenceHandler(t) + user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password") + otherUser := testutil.CreateTestUser(t, db, "other", "other@test.com", "password") + residence := testutil.CreateTestResidence(t, db, user.ID, "Test House") + + authGroup := router.Group("/api/residences") + authGroup.Use(testutil.MockAuthMiddleware(user)) + authGroup.GET("/:id/", handler.GetResidence) + + otherAuthGroup := router.Group("/api/other-residences") + otherAuthGroup.Use(testutil.MockAuthMiddleware(otherUser)) + otherAuthGroup.GET("/:id/", handler.GetResidence) + + t.Run("get own residence", func(t *testing.T) { + w := testutil.MakeRequest(router, "GET", fmt.Sprintf("/api/residences/%d/", residence.ID), nil, "test-token") + + testutil.AssertStatusCode(t, w, http.StatusOK) + + var response map[string]interface{} + err := json.Unmarshal(w.Body.Bytes(), &response) + require.NoError(t, err) + + assert.Equal(t, "Test House", response["name"]) + assert.Equal(t, float64(residence.ID), response["id"]) + }) + + t.Run("get residence with invalid ID", func(t *testing.T) { + w := testutil.MakeRequest(router, "GET", "/api/residences/invalid/", nil, "test-token") + + testutil.AssertStatusCode(t, w, http.StatusBadRequest) + }) + + t.Run("get non-existent residence", func(t *testing.T) { + w := testutil.MakeRequest(router, "GET", "/api/residences/9999/", nil, "test-token") + + // Returns 403 (access denied) rather than 404 to not reveal whether an ID exists + testutil.AssertStatusCode(t, w, http.StatusForbidden) + }) + + t.Run("access denied for other user", func(t *testing.T) { + w := testutil.MakeRequest(router, "GET", fmt.Sprintf("/api/other-residences/%d/", residence.ID), nil, "test-token") + + testutil.AssertStatusCode(t, w, http.StatusForbidden) + }) +} + +func TestResidenceHandler_ListResidences(t *testing.T) { + handler, router, db := setupResidenceHandler(t) + user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password") + testutil.CreateTestResidence(t, db, user.ID, "House 1") + testutil.CreateTestResidence(t, db, user.ID, "House 2") + + authGroup := router.Group("/api/residences") + authGroup.Use(testutil.MockAuthMiddleware(user)) + authGroup.GET("/", handler.ListResidences) + + t.Run("list residences", func(t *testing.T) { + w := testutil.MakeRequest(router, "GET", "/api/residences/", nil, "test-token") + + testutil.AssertStatusCode(t, w, http.StatusOK) + + var response []map[string]interface{} + err := json.Unmarshal(w.Body.Bytes(), &response) + require.NoError(t, err) + + assert.Len(t, response, 2) + }) +} + +func TestResidenceHandler_UpdateResidence(t *testing.T) { + handler, router, db := setupResidenceHandler(t) + user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password") + sharedUser := testutil.CreateTestUser(t, db, "shared", "shared@test.com", "password") + residence := testutil.CreateTestResidence(t, db, user.ID, "Original Name") + + // Share with user + residenceRepo := repositories.NewResidenceRepository(db) + residenceRepo.AddUser(residence.ID, sharedUser.ID) + + authGroup := router.Group("/api/residences") + authGroup.Use(testutil.MockAuthMiddleware(user)) + authGroup.PUT("/:id/", handler.UpdateResidence) + + sharedGroup := router.Group("/api/shared-residences") + sharedGroup.Use(testutil.MockAuthMiddleware(sharedUser)) + sharedGroup.PUT("/:id/", handler.UpdateResidence) + + t.Run("owner can update", func(t *testing.T) { + newName := "Updated Name" + newCity := "Dallas" + req := requests.UpdateResidenceRequest{ + Name: &newName, + City: &newCity, + } + + w := testutil.MakeRequest(router, "PUT", fmt.Sprintf("/api/residences/%d/", residence.ID), req, "test-token") + + testutil.AssertStatusCode(t, w, http.StatusOK) + + var response map[string]interface{} + err := json.Unmarshal(w.Body.Bytes(), &response) + require.NoError(t, err) + + assert.Equal(t, "Updated Name", response["name"]) + assert.Equal(t, "Dallas", response["city"]) + }) + + t.Run("shared user cannot update", func(t *testing.T) { + newName := "Hacked Name" + req := requests.UpdateResidenceRequest{ + Name: &newName, + } + + w := testutil.MakeRequest(router, "PUT", fmt.Sprintf("/api/shared-residences/%d/", residence.ID), req, "test-token") + + testutil.AssertStatusCode(t, w, http.StatusForbidden) + }) +} + +func TestResidenceHandler_DeleteResidence(t *testing.T) { + handler, router, db := setupResidenceHandler(t) + user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password") + sharedUser := testutil.CreateTestUser(t, db, "shared", "shared@test.com", "password") + residence := testutil.CreateTestResidence(t, db, user.ID, "To Delete") + + residenceRepo := repositories.NewResidenceRepository(db) + residenceRepo.AddUser(residence.ID, sharedUser.ID) + + authGroup := router.Group("/api/residences") + authGroup.Use(testutil.MockAuthMiddleware(user)) + authGroup.DELETE("/:id/", handler.DeleteResidence) + + sharedGroup := router.Group("/api/shared-residences") + sharedGroup.Use(testutil.MockAuthMiddleware(sharedUser)) + sharedGroup.DELETE("/:id/", handler.DeleteResidence) + + t.Run("shared user cannot delete", func(t *testing.T) { + w := testutil.MakeRequest(router, "DELETE", fmt.Sprintf("/api/shared-residences/%d/", residence.ID), nil, "test-token") + + testutil.AssertStatusCode(t, w, http.StatusForbidden) + }) + + t.Run("owner can delete", func(t *testing.T) { + w := testutil.MakeRequest(router, "DELETE", fmt.Sprintf("/api/residences/%d/", residence.ID), nil, "test-token") + + testutil.AssertStatusCode(t, w, http.StatusOK) + + response := testutil.ParseJSON(t, w.Body.Bytes()) + assert.Contains(t, response["message"], "deleted") + }) +} + +func TestResidenceHandler_GenerateShareCode(t *testing.T) { + handler, router, db := setupResidenceHandler(t) + user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password") + residence := testutil.CreateTestResidence(t, db, user.ID, "Share Test") + + authGroup := router.Group("/api/residences") + authGroup.Use(testutil.MockAuthMiddleware(user)) + authGroup.POST("/:id/generate-share-code/", handler.GenerateShareCode) + + t.Run("generate share code", func(t *testing.T) { + req := requests.GenerateShareCodeRequest{ + ExpiresInHours: 24, + } + + w := testutil.MakeRequest(router, "POST", fmt.Sprintf("/api/residences/%d/generate-share-code/", residence.ID), req, "test-token") + + testutil.AssertStatusCode(t, w, http.StatusOK) + + var response map[string]interface{} + err := json.Unmarshal(w.Body.Bytes(), &response) + require.NoError(t, err) + + shareCode := response["share_code"].(map[string]interface{}) + code := shareCode["code"].(string) + assert.Len(t, code, 6) + assert.NotEmpty(t, shareCode["expires_at"]) + }) +} + +func TestResidenceHandler_JoinWithCode(t *testing.T) { + handler, router, db := setupResidenceHandler(t) + owner := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password") + newUser := testutil.CreateTestUser(t, db, "newuser", "new@test.com", "password") + residence := testutil.CreateTestResidence(t, db, owner.ID, "Join Test") + + // Generate share code first + residenceRepo := repositories.NewResidenceRepository(db) + userRepo := repositories.NewUserRepository(db) + cfg := &config.Config{} + residenceService := services.NewResidenceService(residenceRepo, userRepo, cfg) + shareResp, _ := residenceService.GenerateShareCode(residence.ID, owner.ID, 24) + + authGroup := router.Group("/api/residences") + authGroup.Use(testutil.MockAuthMiddleware(newUser)) + authGroup.POST("/join-with-code/", handler.JoinWithCode) + + ownerGroup := router.Group("/api/owner-residences") + ownerGroup.Use(testutil.MockAuthMiddleware(owner)) + ownerGroup.POST("/join-with-code/", handler.JoinWithCode) + + t.Run("join with valid code", func(t *testing.T) { + req := requests.JoinWithCodeRequest{ + Code: shareResp.ShareCode.Code, + } + + w := testutil.MakeRequest(router, "POST", "/api/residences/join-with-code/", req, "test-token") + + testutil.AssertStatusCode(t, w, http.StatusOK) + + var response map[string]interface{} + err := json.Unmarshal(w.Body.Bytes(), &response) + require.NoError(t, err) + + residenceResp := response["residence"].(map[string]interface{}) + assert.Equal(t, "Join Test", residenceResp["name"]) + }) + + t.Run("owner tries to join own residence", func(t *testing.T) { + // Generate new code + shareResp2, _ := residenceService.GenerateShareCode(residence.ID, owner.ID, 24) + + req := requests.JoinWithCodeRequest{ + Code: shareResp2.ShareCode.Code, + } + + w := testutil.MakeRequest(router, "POST", "/api/owner-residences/join-with-code/", req, "test-token") + + testutil.AssertStatusCode(t, w, http.StatusConflict) + }) + + t.Run("join with invalid code", func(t *testing.T) { + req := requests.JoinWithCodeRequest{ + Code: "ABCDEF", // Valid length (6) but non-existent code + } + + w := testutil.MakeRequest(router, "POST", "/api/residences/join-with-code/", req, "test-token") + + testutil.AssertStatusCode(t, w, http.StatusNotFound) + }) +} + +func TestResidenceHandler_GetResidenceUsers(t *testing.T) { + handler, router, db := setupResidenceHandler(t) + owner := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password") + sharedUser := testutil.CreateTestUser(t, db, "shared", "shared@test.com", "password") + residence := testutil.CreateTestResidence(t, db, owner.ID, "Users Test") + + residenceRepo := repositories.NewResidenceRepository(db) + residenceRepo.AddUser(residence.ID, sharedUser.ID) + + authGroup := router.Group("/api/residences") + authGroup.Use(testutil.MockAuthMiddleware(owner)) + authGroup.GET("/:id/users/", handler.GetResidenceUsers) + + t.Run("get residence users", func(t *testing.T) { + w := testutil.MakeRequest(router, "GET", fmt.Sprintf("/api/residences/%d/users/", residence.ID), nil, "test-token") + + testutil.AssertStatusCode(t, w, http.StatusOK) + + var response []map[string]interface{} + err := json.Unmarshal(w.Body.Bytes(), &response) + require.NoError(t, err) + + assert.Len(t, response, 2) // owner + shared user + }) +} + +func TestResidenceHandler_RemoveUser(t *testing.T) { + handler, router, db := setupResidenceHandler(t) + owner := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password") + sharedUser := testutil.CreateTestUser(t, db, "shared", "shared@test.com", "password") + residence := testutil.CreateTestResidence(t, db, owner.ID, "Remove Test") + + residenceRepo := repositories.NewResidenceRepository(db) + residenceRepo.AddUser(residence.ID, sharedUser.ID) + + authGroup := router.Group("/api/residences") + authGroup.Use(testutil.MockAuthMiddleware(owner)) + authGroup.DELETE("/:id/users/:user_id/", handler.RemoveResidenceUser) + + t.Run("remove shared user", func(t *testing.T) { + w := testutil.MakeRequest(router, "DELETE", fmt.Sprintf("/api/residences/%d/users/%d/", residence.ID, sharedUser.ID), nil, "test-token") + + testutil.AssertStatusCode(t, w, http.StatusOK) + + response := testutil.ParseJSON(t, w.Body.Bytes()) + assert.Contains(t, response["message"], "removed") + }) + + t.Run("cannot remove owner", func(t *testing.T) { + w := testutil.MakeRequest(router, "DELETE", fmt.Sprintf("/api/residences/%d/users/%d/", residence.ID, owner.ID), nil, "test-token") + + testutil.AssertStatusCode(t, w, http.StatusBadRequest) + }) +} + +func TestResidenceHandler_GetResidenceTypes(t *testing.T) { + handler, router, db := setupResidenceHandler(t) + testutil.SeedLookupData(t, db) + user := testutil.CreateTestUser(t, db, "user", "user@test.com", "password") + + authGroup := router.Group("/api/residences") + authGroup.Use(testutil.MockAuthMiddleware(user)) + authGroup.GET("/types/", handler.GetResidenceTypes) + + t.Run("get residence types", func(t *testing.T) { + w := testutil.MakeRequest(router, "GET", "/api/residences/types/", nil, "test-token") + + testutil.AssertStatusCode(t, w, http.StatusOK) + + var response []map[string]interface{} + err := json.Unmarshal(w.Body.Bytes(), &response) + require.NoError(t, err) + + assert.Greater(t, len(response), 0) + }) +} + +func TestResidenceHandler_JSONResponses(t *testing.T) { + handler, router, db := setupResidenceHandler(t) + user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password") + + authGroup := router.Group("/api/residences") + authGroup.Use(testutil.MockAuthMiddleware(user)) + authGroup.POST("/", handler.CreateResidence) + authGroup.GET("/", handler.ListResidences) + + t.Run("residence response has correct JSON structure", func(t *testing.T) { + req := requests.CreateResidenceRequest{ + Name: "JSON Test House", + StreetAddress: "123 Test St", + City: "Austin", + StateProvince: "TX", + PostalCode: "78701", + } + + w := testutil.MakeRequest(router, "POST", "/api/residences/", req, "test-token") + + testutil.AssertStatusCode(t, w, http.StatusCreated) + + var response map[string]interface{} + err := json.Unmarshal(w.Body.Bytes(), &response) + require.NoError(t, err) + + // Required fields + assert.Contains(t, response, "id") + assert.Contains(t, response, "name") + assert.Contains(t, response, "street_address") + assert.Contains(t, response, "city") + assert.Contains(t, response, "state_province") + assert.Contains(t, response, "postal_code") + assert.Contains(t, response, "country") + assert.Contains(t, response, "is_primary") + assert.Contains(t, response, "is_active") + assert.Contains(t, response, "created_at") + assert.Contains(t, response, "updated_at") + + // Type checks + assert.IsType(t, float64(0), response["id"]) + assert.IsType(t, "", response["name"]) + assert.IsType(t, true, response["is_primary"]) + }) + + t.Run("list response returns array", func(t *testing.T) { + w := testutil.MakeRequest(router, "GET", "/api/residences/", nil, "test-token") + + testutil.AssertStatusCode(t, w, http.StatusOK) + + var response []map[string]interface{} + err := json.Unmarshal(w.Body.Bytes(), &response) + require.NoError(t, err) + + // Response should be an array of residences + assert.IsType(t, []map[string]interface{}{}, response) + }) +} diff --git a/internal/handlers/task_handler.go b/internal/handlers/task_handler.go index b2ce89b..f54f6f3 100644 --- a/internal/handlers/task_handler.go +++ b/internal/handlers/task_handler.go @@ -2,10 +2,14 @@ package handlers import ( "errors" + "mime/multipart" "net/http" "strconv" + "strings" + "time" "github.com/gin-gonic/gin" + "github.com/shopspring/decimal" "github.com/treytartt/mycrib-api/internal/dto/requests" "github.com/treytartt/mycrib-api/internal/middleware" @@ -15,12 +19,16 @@ import ( // TaskHandler handles task-related HTTP requests type TaskHandler struct { - taskService *services.TaskService + taskService *services.TaskService + storageService *services.StorageService } // NewTaskHandler creates a new task handler -func NewTaskHandler(taskService *services.TaskService) *TaskHandler { - return &TaskHandler{taskService: taskService} +func NewTaskHandler(taskService *services.TaskService, storageService *services.StorageService) *TaskHandler { + return &TaskHandler{ + taskService: taskService, + storageService: storageService, + } } // ListTasks handles GET /api/tasks/ @@ -288,6 +296,30 @@ func (h *TaskHandler) UnarchiveTask(c *gin.Context) { // === Task Completions === +// GetTaskCompletions handles GET /api/tasks/:id/completions/ +func (h *TaskHandler) GetTaskCompletions(c *gin.Context) { + user := c.MustGet(middleware.AuthUserKey).(*models.User) + taskID, err := strconv.ParseUint(c.Param("id"), 10, 32) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid task ID"}) + return + } + + response, err := h.taskService.GetCompletionsByTask(uint(taskID), user.ID) + if err != nil { + switch { + case errors.Is(err, services.ErrTaskNotFound): + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + case errors.Is(err, services.ErrTaskAccessDenied): + c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) + default: + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + } + return + } + c.JSON(http.StatusOK, response) +} + // ListCompletions handles GET /api/task-completions/ func (h *TaskHandler) ListCompletions(c *gin.Context) { user := c.MustGet(middleware.AuthUserKey).(*models.User) @@ -324,12 +356,78 @@ func (h *TaskHandler) GetCompletion(c *gin.Context) { } // CreateCompletion handles POST /api/task-completions/ +// Supports both JSON and multipart form data (for image uploads) func (h *TaskHandler) CreateCompletion(c *gin.Context) { user := c.MustGet(middleware.AuthUserKey).(*models.User) var req requests.CreateTaskCompletionRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return + + contentType := c.GetHeader("Content-Type") + + // Check if this is a multipart form request (image upload) + if strings.HasPrefix(contentType, "multipart/form-data") { + // Parse multipart form + if err := c.Request.ParseMultipartForm(32 << 20); err != nil { // 32MB max + c.JSON(http.StatusBadRequest, gin.H{"error": "failed to parse multipart form: " + err.Error()}) + return + } + + // Parse task_id (required) + taskIDStr := c.PostForm("task_id") + if taskIDStr == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "task_id is required"}) + return + } + taskID, err := strconv.ParseUint(taskIDStr, 10, 32) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid task_id"}) + return + } + req.TaskID = uint(taskID) + + // Parse notes (optional) + req.Notes = c.PostForm("notes") + + // Parse actual_cost (optional) + if costStr := c.PostForm("actual_cost"); costStr != "" { + cost, err := decimal.NewFromString(costStr) + if err == nil { + req.ActualCost = &cost + } + } + + // Parse completed_at (optional) + if completedAtStr := c.PostForm("completed_at"); completedAtStr != "" { + if t, err := time.Parse(time.RFC3339, completedAtStr); err == nil { + req.CompletedAt = &t + } + } + + // Handle image upload (look for "images" or "image" or "photo" field) + var imageFile interface{} + for _, fieldName := range []string{"images", "image", "photo"} { + if file, err := c.FormFile(fieldName); err == nil { + imageFile = file + break + } + } + + if imageFile != nil { + file := imageFile.(*multipart.FileHeader) + if h.storageService != nil { + result, err := h.storageService.Upload(file, "completions") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "failed to upload image: " + err.Error()}) + return + } + req.PhotoURL = result.URL + } + } + } else { + // Standard JSON request + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } } response, err := h.taskService.CreateCompletion(&req, user.ID) diff --git a/internal/handlers/task_handler_test.go b/internal/handlers/task_handler_test.go new file mode 100644 index 0000000..582d312 --- /dev/null +++ b/internal/handlers/task_handler_test.go @@ -0,0 +1,666 @@ +package handlers + +import ( + "encoding/json" + "fmt" + "net/http" + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/shopspring/decimal" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/treytartt/mycrib-api/internal/dto/requests" + "github.com/treytartt/mycrib-api/internal/models" + "github.com/treytartt/mycrib-api/internal/repositories" + "github.com/treytartt/mycrib-api/internal/services" + "github.com/treytartt/mycrib-api/internal/testutil" + "gorm.io/gorm" +) + +func setupTaskHandler(t *testing.T) (*TaskHandler, *gin.Engine, *gorm.DB) { + db := testutil.SetupTestDB(t) + taskRepo := repositories.NewTaskRepository(db) + residenceRepo := repositories.NewResidenceRepository(db) + taskService := services.NewTaskService(taskRepo, residenceRepo) + handler := NewTaskHandler(taskService, nil) + router := testutil.SetupTestRouter() + return handler, router, db +} + +func TestTaskHandler_CreateTask(t *testing.T) { + handler, router, db := setupTaskHandler(t) + testutil.SeedLookupData(t, db) + user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password") + residence := testutil.CreateTestResidence(t, db, user.ID, "Test House") + + authGroup := router.Group("/api/tasks") + authGroup.Use(testutil.MockAuthMiddleware(user)) + authGroup.POST("/", handler.CreateTask) + + t.Run("successful task creation", func(t *testing.T) { + req := requests.CreateTaskRequest{ + ResidenceID: residence.ID, + Title: "Fix leaky faucet", + Description: "Kitchen faucet is dripping", + } + + w := testutil.MakeRequest(router, "POST", "/api/tasks/", req, "test-token") + + testutil.AssertStatusCode(t, w, http.StatusCreated) + + var response map[string]interface{} + err := json.Unmarshal(w.Body.Bytes(), &response) + require.NoError(t, err) + + assert.Equal(t, "Fix leaky faucet", response["title"]) + assert.Equal(t, "Kitchen faucet is dripping", response["description"]) + assert.Equal(t, float64(residence.ID), response["residence_id"]) + assert.Equal(t, false, response["is_cancelled"]) + assert.Equal(t, false, response["is_archived"]) + }) + + t.Run("task creation with optional fields", func(t *testing.T) { + var category models.TaskCategory + db.First(&category) + var priority models.TaskPriority + db.First(&priority) + + dueDate := requests.FlexibleDate{Time: time.Now().AddDate(0, 0, 7)} + estimatedCost := decimal.NewFromFloat(150.50) + + req := requests.CreateTaskRequest{ + ResidenceID: residence.ID, + Title: "Install new lights", + Description: "Replace old light fixtures", + CategoryID: &category.ID, + PriorityID: &priority.ID, + DueDate: &dueDate, + EstimatedCost: &estimatedCost, + } + + w := testutil.MakeRequest(router, "POST", "/api/tasks/", req, "test-token") + + testutil.AssertStatusCode(t, w, http.StatusCreated) + + var response map[string]interface{} + err := json.Unmarshal(w.Body.Bytes(), &response) + require.NoError(t, err) + + assert.Equal(t, "Install new lights", response["title"]) + assert.NotNil(t, response["category"]) + assert.NotNil(t, response["priority"]) + assert.Equal(t, "150.5", response["estimated_cost"]) // Decimal serializes as string + }) + + t.Run("task creation without residence access", func(t *testing.T) { + otherUser := testutil.CreateTestUser(t, db, "other", "other@test.com", "password") + otherResidence := testutil.CreateTestResidence(t, db, otherUser.ID, "Other House") + + req := requests.CreateTaskRequest{ + ResidenceID: otherResidence.ID, + Title: "Unauthorized Task", + } + + w := testutil.MakeRequest(router, "POST", "/api/tasks/", req, "test-token") + + testutil.AssertStatusCode(t, w, http.StatusForbidden) + }) +} + +func TestTaskHandler_GetTask(t *testing.T) { + handler, router, db := setupTaskHandler(t) + user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password") + otherUser := testutil.CreateTestUser(t, db, "other", "other@test.com", "password") + residence := testutil.CreateTestResidence(t, db, user.ID, "Test House") + task := testutil.CreateTestTask(t, db, residence.ID, user.ID, "Test Task") + + authGroup := router.Group("/api/tasks") + authGroup.Use(testutil.MockAuthMiddleware(user)) + authGroup.GET("/:id/", handler.GetTask) + + otherGroup := router.Group("/api/other-tasks") + otherGroup.Use(testutil.MockAuthMiddleware(otherUser)) + otherGroup.GET("/:id/", handler.GetTask) + + t.Run("get own task", func(t *testing.T) { + w := testutil.MakeRequest(router, "GET", fmt.Sprintf("/api/tasks/%d/", task.ID), nil, "test-token") + + testutil.AssertStatusCode(t, w, http.StatusOK) + + var response map[string]interface{} + err := json.Unmarshal(w.Body.Bytes(), &response) + require.NoError(t, err) + + assert.Equal(t, "Test Task", response["title"]) + assert.Equal(t, float64(task.ID), response["id"]) + }) + + t.Run("get non-existent task", func(t *testing.T) { + w := testutil.MakeRequest(router, "GET", "/api/tasks/9999/", nil, "test-token") + + testutil.AssertStatusCode(t, w, http.StatusNotFound) + }) + + t.Run("access denied for other user", func(t *testing.T) { + w := testutil.MakeRequest(router, "GET", fmt.Sprintf("/api/other-tasks/%d/", task.ID), nil, "test-token") + + testutil.AssertStatusCode(t, w, http.StatusForbidden) + }) +} + +func TestTaskHandler_ListTasks(t *testing.T) { + handler, router, db := setupTaskHandler(t) + user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password") + residence := testutil.CreateTestResidence(t, db, user.ID, "Test House") + testutil.CreateTestTask(t, db, residence.ID, user.ID, "Task 1") + testutil.CreateTestTask(t, db, residence.ID, user.ID, "Task 2") + testutil.CreateTestTask(t, db, residence.ID, user.ID, "Task 3") + + authGroup := router.Group("/api/tasks") + authGroup.Use(testutil.MockAuthMiddleware(user)) + authGroup.GET("/", handler.ListTasks) + + t.Run("list tasks", func(t *testing.T) { + w := testutil.MakeRequest(router, "GET", "/api/tasks/", nil, "test-token") + + testutil.AssertStatusCode(t, w, http.StatusOK) + + var response []map[string]interface{} + err := json.Unmarshal(w.Body.Bytes(), &response) + require.NoError(t, err) + + assert.Len(t, response, 3) + }) +} + +func TestTaskHandler_GetTasksByResidence(t *testing.T) { + handler, router, db := setupTaskHandler(t) + testutil.SeedLookupData(t, db) + user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password") + residence := testutil.CreateTestResidence(t, db, user.ID, "Test House") + + // Create tasks with different states + testutil.CreateTestTask(t, db, residence.ID, user.ID, "Active Task") + + authGroup := router.Group("/api/tasks") + authGroup.Use(testutil.MockAuthMiddleware(user)) + authGroup.GET("/by-residence/:residence_id/", handler.GetTasksByResidence) + + t.Run("get kanban columns", func(t *testing.T) { + w := testutil.MakeRequest(router, "GET", fmt.Sprintf("/api/tasks/by-residence/%d/", residence.ID), nil, "test-token") + + testutil.AssertStatusCode(t, w, http.StatusOK) + + var response map[string]interface{} + err := json.Unmarshal(w.Body.Bytes(), &response) + require.NoError(t, err) + + assert.Contains(t, response, "columns") + assert.Contains(t, response, "days_threshold") + assert.Contains(t, response, "residence_id") + + columns := response["columns"].([]interface{}) + assert.Len(t, columns, 6) // 6 kanban columns + }) + + t.Run("kanban column structure", func(t *testing.T) { + w := testutil.MakeRequest(router, "GET", fmt.Sprintf("/api/tasks/by-residence/%d/", residence.ID), nil, "test-token") + + testutil.AssertStatusCode(t, w, http.StatusOK) + + var response map[string]interface{} + err := json.Unmarshal(w.Body.Bytes(), &response) + require.NoError(t, err) + + columns := response["columns"].([]interface{}) + firstColumn := columns[0].(map[string]interface{}) + + // Verify column structure + assert.Contains(t, firstColumn, "name") + assert.Contains(t, firstColumn, "display_name") + assert.Contains(t, firstColumn, "tasks") + assert.Contains(t, firstColumn, "count") + assert.Contains(t, firstColumn, "color") + assert.Contains(t, firstColumn, "icons") + assert.Contains(t, firstColumn, "button_types") + }) +} + +func TestTaskHandler_UpdateTask(t *testing.T) { + handler, router, db := setupTaskHandler(t) + user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password") + residence := testutil.CreateTestResidence(t, db, user.ID, "Test House") + task := testutil.CreateTestTask(t, db, residence.ID, user.ID, "Original Title") + + authGroup := router.Group("/api/tasks") + authGroup.Use(testutil.MockAuthMiddleware(user)) + authGroup.PUT("/:id/", handler.UpdateTask) + + t.Run("update task", func(t *testing.T) { + newTitle := "Updated Title" + newDesc := "Updated description" + req := requests.UpdateTaskRequest{ + Title: &newTitle, + Description: &newDesc, + } + + w := testutil.MakeRequest(router, "PUT", fmt.Sprintf("/api/tasks/%d/", task.ID), req, "test-token") + + testutil.AssertStatusCode(t, w, http.StatusOK) + + var response map[string]interface{} + err := json.Unmarshal(w.Body.Bytes(), &response) + require.NoError(t, err) + + assert.Equal(t, "Updated Title", response["title"]) + assert.Equal(t, "Updated description", response["description"]) + }) +} + +func TestTaskHandler_DeleteTask(t *testing.T) { + handler, router, db := setupTaskHandler(t) + user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password") + residence := testutil.CreateTestResidence(t, db, user.ID, "Test House") + task := testutil.CreateTestTask(t, db, residence.ID, user.ID, "To Delete") + + authGroup := router.Group("/api/tasks") + authGroup.Use(testutil.MockAuthMiddleware(user)) + authGroup.DELETE("/:id/", handler.DeleteTask) + + t.Run("delete task", func(t *testing.T) { + w := testutil.MakeRequest(router, "DELETE", fmt.Sprintf("/api/tasks/%d/", task.ID), nil, "test-token") + + testutil.AssertStatusCode(t, w, http.StatusOK) + + response := testutil.ParseJSON(t, w.Body.Bytes()) + assert.Contains(t, response["message"], "deleted") + }) +} + +func TestTaskHandler_CancelTask(t *testing.T) { + handler, router, db := setupTaskHandler(t) + user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password") + residence := testutil.CreateTestResidence(t, db, user.ID, "Test House") + task := testutil.CreateTestTask(t, db, residence.ID, user.ID, "To Cancel") + + authGroup := router.Group("/api/tasks") + authGroup.Use(testutil.MockAuthMiddleware(user)) + authGroup.POST("/:id/cancel/", handler.CancelTask) + + t.Run("cancel task", func(t *testing.T) { + w := testutil.MakeRequest(router, "POST", fmt.Sprintf("/api/tasks/%d/cancel/", task.ID), nil, "test-token") + + testutil.AssertStatusCode(t, w, http.StatusOK) + + var response map[string]interface{} + err := json.Unmarshal(w.Body.Bytes(), &response) + require.NoError(t, err) + + assert.Contains(t, response["message"], "cancelled") + + taskResp := response["task"].(map[string]interface{}) + assert.Equal(t, true, taskResp["is_cancelled"]) + }) + + t.Run("cancel already cancelled task", func(t *testing.T) { + // Already cancelled from previous test + w := testutil.MakeRequest(router, "POST", fmt.Sprintf("/api/tasks/%d/cancel/", task.ID), nil, "test-token") + + testutil.AssertStatusCode(t, w, http.StatusBadRequest) + }) +} + +func TestTaskHandler_UncancelTask(t *testing.T) { + handler, router, db := setupTaskHandler(t) + user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password") + residence := testutil.CreateTestResidence(t, db, user.ID, "Test House") + task := testutil.CreateTestTask(t, db, residence.ID, user.ID, "To Uncancel") + + // Cancel first + taskRepo := repositories.NewTaskRepository(db) + taskRepo.Cancel(task.ID) + + authGroup := router.Group("/api/tasks") + authGroup.Use(testutil.MockAuthMiddleware(user)) + authGroup.POST("/:id/uncancel/", handler.UncancelTask) + + t.Run("uncancel task", func(t *testing.T) { + w := testutil.MakeRequest(router, "POST", fmt.Sprintf("/api/tasks/%d/uncancel/", task.ID), nil, "test-token") + + testutil.AssertStatusCode(t, w, http.StatusOK) + + var response map[string]interface{} + err := json.Unmarshal(w.Body.Bytes(), &response) + require.NoError(t, err) + + taskResp := response["task"].(map[string]interface{}) + assert.Equal(t, false, taskResp["is_cancelled"]) + }) +} + +func TestTaskHandler_ArchiveTask(t *testing.T) { + handler, router, db := setupTaskHandler(t) + user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password") + residence := testutil.CreateTestResidence(t, db, user.ID, "Test House") + task := testutil.CreateTestTask(t, db, residence.ID, user.ID, "To Archive") + + authGroup := router.Group("/api/tasks") + authGroup.Use(testutil.MockAuthMiddleware(user)) + authGroup.POST("/:id/archive/", handler.ArchiveTask) + + t.Run("archive task", func(t *testing.T) { + w := testutil.MakeRequest(router, "POST", fmt.Sprintf("/api/tasks/%d/archive/", task.ID), nil, "test-token") + + testutil.AssertStatusCode(t, w, http.StatusOK) + + var response map[string]interface{} + err := json.Unmarshal(w.Body.Bytes(), &response) + require.NoError(t, err) + + taskResp := response["task"].(map[string]interface{}) + assert.Equal(t, true, taskResp["is_archived"]) + }) +} + +func TestTaskHandler_UnarchiveTask(t *testing.T) { + handler, router, db := setupTaskHandler(t) + user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password") + residence := testutil.CreateTestResidence(t, db, user.ID, "Test House") + task := testutil.CreateTestTask(t, db, residence.ID, user.ID, "To Unarchive") + + // Archive first + taskRepo := repositories.NewTaskRepository(db) + taskRepo.Archive(task.ID) + + authGroup := router.Group("/api/tasks") + authGroup.Use(testutil.MockAuthMiddleware(user)) + authGroup.POST("/:id/unarchive/", handler.UnarchiveTask) + + t.Run("unarchive task", func(t *testing.T) { + w := testutil.MakeRequest(router, "POST", fmt.Sprintf("/api/tasks/%d/unarchive/", task.ID), nil, "test-token") + + testutil.AssertStatusCode(t, w, http.StatusOK) + + var response map[string]interface{} + err := json.Unmarshal(w.Body.Bytes(), &response) + require.NoError(t, err) + + taskResp := response["task"].(map[string]interface{}) + assert.Equal(t, false, taskResp["is_archived"]) + }) +} + +func TestTaskHandler_MarkInProgress(t *testing.T) { + handler, router, db := setupTaskHandler(t) + testutil.SeedLookupData(t, db) + user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password") + residence := testutil.CreateTestResidence(t, db, user.ID, "Test House") + task := testutil.CreateTestTask(t, db, residence.ID, user.ID, "To Start") + + authGroup := router.Group("/api/tasks") + authGroup.Use(testutil.MockAuthMiddleware(user)) + authGroup.POST("/:id/mark-in-progress/", handler.MarkInProgress) + + t.Run("mark in progress", func(t *testing.T) { + w := testutil.MakeRequest(router, "POST", fmt.Sprintf("/api/tasks/%d/mark-in-progress/", task.ID), nil, "test-token") + + testutil.AssertStatusCode(t, w, http.StatusOK) + + var response map[string]interface{} + err := json.Unmarshal(w.Body.Bytes(), &response) + require.NoError(t, err) + + assert.Contains(t, response["message"], "in progress") + assert.NotNil(t, response["task"]) + }) +} + +func TestTaskHandler_CreateCompletion(t *testing.T) { + handler, router, db := setupTaskHandler(t) + user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password") + residence := testutil.CreateTestResidence(t, db, user.ID, "Test House") + task := testutil.CreateTestTask(t, db, residence.ID, user.ID, "To Complete") + + authGroup := router.Group("/api/task-completions") + authGroup.Use(testutil.MockAuthMiddleware(user)) + authGroup.POST("/", handler.CreateCompletion) + + t.Run("create completion", func(t *testing.T) { + completedAt := time.Now().UTC() + req := requests.CreateTaskCompletionRequest{ + TaskID: task.ID, + CompletedAt: &completedAt, + Notes: "Completed successfully", + } + + w := testutil.MakeRequest(router, "POST", "/api/task-completions/", req, "test-token") + + testutil.AssertStatusCode(t, w, http.StatusCreated) + + var response map[string]interface{} + err := json.Unmarshal(w.Body.Bytes(), &response) + require.NoError(t, err) + + testutil.AssertJSONFieldExists(t, response, "id") + assert.Equal(t, float64(task.ID), response["task_id"]) + assert.Equal(t, "Completed successfully", response["notes"]) + }) +} + +func TestTaskHandler_ListCompletions(t *testing.T) { + handler, router, db := setupTaskHandler(t) + user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password") + residence := testutil.CreateTestResidence(t, db, user.ID, "Test House") + task := testutil.CreateTestTask(t, db, residence.ID, user.ID, "Test Task") + + // Create completions + for i := 0; i < 3; i++ { + db.Create(&models.TaskCompletion{ + TaskID: task.ID, + CompletedByID: user.ID, + CompletedAt: time.Now().UTC(), + }) + } + + authGroup := router.Group("/api/task-completions") + authGroup.Use(testutil.MockAuthMiddleware(user)) + authGroup.GET("/", handler.ListCompletions) + + t.Run("list completions", func(t *testing.T) { + w := testutil.MakeRequest(router, "GET", "/api/task-completions/", nil, "test-token") + + testutil.AssertStatusCode(t, w, http.StatusOK) + + var response []map[string]interface{} + err := json.Unmarshal(w.Body.Bytes(), &response) + require.NoError(t, err) + + assert.Len(t, response, 3) + }) +} + +func TestTaskHandler_GetCompletion(t *testing.T) { + handler, router, db := setupTaskHandler(t) + user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password") + residence := testutil.CreateTestResidence(t, db, user.ID, "Test House") + task := testutil.CreateTestTask(t, db, residence.ID, user.ID, "Test Task") + + completion := &models.TaskCompletion{ + TaskID: task.ID, + CompletedByID: user.ID, + CompletedAt: time.Now().UTC(), + Notes: "Test completion", + } + db.Create(completion) + + authGroup := router.Group("/api/task-completions") + authGroup.Use(testutil.MockAuthMiddleware(user)) + authGroup.GET("/:id/", handler.GetCompletion) + + t.Run("get completion", func(t *testing.T) { + w := testutil.MakeRequest(router, "GET", fmt.Sprintf("/api/task-completions/%d/", completion.ID), nil, "test-token") + + testutil.AssertStatusCode(t, w, http.StatusOK) + + var response map[string]interface{} + err := json.Unmarshal(w.Body.Bytes(), &response) + require.NoError(t, err) + + assert.Equal(t, float64(completion.ID), response["id"]) + assert.Equal(t, "Test completion", response["notes"]) + }) +} + +func TestTaskHandler_DeleteCompletion(t *testing.T) { + handler, router, db := setupTaskHandler(t) + user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password") + residence := testutil.CreateTestResidence(t, db, user.ID, "Test House") + task := testutil.CreateTestTask(t, db, residence.ID, user.ID, "Test Task") + + completion := &models.TaskCompletion{ + TaskID: task.ID, + CompletedByID: user.ID, + CompletedAt: time.Now().UTC(), + } + db.Create(completion) + + authGroup := router.Group("/api/task-completions") + authGroup.Use(testutil.MockAuthMiddleware(user)) + authGroup.DELETE("/:id/", handler.DeleteCompletion) + + t.Run("delete completion", func(t *testing.T) { + w := testutil.MakeRequest(router, "DELETE", fmt.Sprintf("/api/task-completions/%d/", completion.ID), nil, "test-token") + + testutil.AssertStatusCode(t, w, http.StatusOK) + + response := testutil.ParseJSON(t, w.Body.Bytes()) + assert.Contains(t, response["message"], "deleted") + }) +} + +func TestTaskHandler_GetLookups(t *testing.T) { + handler, router, db := setupTaskHandler(t) + testutil.SeedLookupData(t, db) + user := testutil.CreateTestUser(t, db, "user", "user@test.com", "password") + + authGroup := router.Group("/api/tasks") + authGroup.Use(testutil.MockAuthMiddleware(user)) + authGroup.GET("/categories/", handler.GetCategories) + authGroup.GET("/priorities/", handler.GetPriorities) + authGroup.GET("/statuses/", handler.GetStatuses) + authGroup.GET("/frequencies/", handler.GetFrequencies) + + t.Run("get categories", func(t *testing.T) { + w := testutil.MakeRequest(router, "GET", "/api/tasks/categories/", nil, "test-token") + + testutil.AssertStatusCode(t, w, http.StatusOK) + + var response []map[string]interface{} + err := json.Unmarshal(w.Body.Bytes(), &response) + require.NoError(t, err) + + assert.Greater(t, len(response), 0) + assert.Contains(t, response[0], "id") + assert.Contains(t, response[0], "name") + }) + + t.Run("get priorities", func(t *testing.T) { + w := testutil.MakeRequest(router, "GET", "/api/tasks/priorities/", nil, "test-token") + + testutil.AssertStatusCode(t, w, http.StatusOK) + + var response []map[string]interface{} + err := json.Unmarshal(w.Body.Bytes(), &response) + require.NoError(t, err) + + assert.Greater(t, len(response), 0) + assert.Contains(t, response[0], "id") + assert.Contains(t, response[0], "name") + assert.Contains(t, response[0], "level") + }) + + t.Run("get statuses", func(t *testing.T) { + w := testutil.MakeRequest(router, "GET", "/api/tasks/statuses/", nil, "test-token") + + testutil.AssertStatusCode(t, w, http.StatusOK) + + var response []map[string]interface{} + err := json.Unmarshal(w.Body.Bytes(), &response) + require.NoError(t, err) + + assert.Greater(t, len(response), 0) + }) + + t.Run("get frequencies", func(t *testing.T) { + w := testutil.MakeRequest(router, "GET", "/api/tasks/frequencies/", nil, "test-token") + + testutil.AssertStatusCode(t, w, http.StatusOK) + + var response []map[string]interface{} + err := json.Unmarshal(w.Body.Bytes(), &response) + require.NoError(t, err) + + assert.Greater(t, len(response), 0) + }) +} + +func TestTaskHandler_JSONResponses(t *testing.T) { + handler, router, db := setupTaskHandler(t) + testutil.SeedLookupData(t, db) + user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password") + residence := testutil.CreateTestResidence(t, db, user.ID, "Test House") + + authGroup := router.Group("/api/tasks") + authGroup.Use(testutil.MockAuthMiddleware(user)) + authGroup.POST("/", handler.CreateTask) + authGroup.GET("/", handler.ListTasks) + + t.Run("task response has correct JSON structure", func(t *testing.T) { + req := requests.CreateTaskRequest{ + ResidenceID: residence.ID, + Title: "JSON Test Task", + Description: "Testing JSON structure", + } + + w := testutil.MakeRequest(router, "POST", "/api/tasks/", req, "test-token") + + testutil.AssertStatusCode(t, w, http.StatusCreated) + + var response map[string]interface{} + err := json.Unmarshal(w.Body.Bytes(), &response) + require.NoError(t, err) + + // Required fields + assert.Contains(t, response, "id") + assert.Contains(t, response, "residence_id") + assert.Contains(t, response, "created_by_id") + assert.Contains(t, response, "title") + assert.Contains(t, response, "description") + assert.Contains(t, response, "is_cancelled") + assert.Contains(t, response, "is_archived") + assert.Contains(t, response, "created_at") + assert.Contains(t, response, "updated_at") + + // Type checks + assert.IsType(t, float64(0), response["id"]) + assert.IsType(t, "", response["title"]) + assert.IsType(t, false, response["is_cancelled"]) + assert.IsType(t, false, response["is_archived"]) + }) + + t.Run("list response returns array", func(t *testing.T) { + w := testutil.MakeRequest(router, "GET", "/api/tasks/", nil, "test-token") + + testutil.AssertStatusCode(t, w, http.StatusOK) + + var response []map[string]interface{} + err := json.Unmarshal(w.Body.Bytes(), &response) + require.NoError(t, err) + + // Response should be an array of tasks + assert.IsType(t, []map[string]interface{}{}, response) + }) +} diff --git a/internal/handlers/upload_handler.go b/internal/handlers/upload_handler.go new file mode 100644 index 0000000..1072fe0 --- /dev/null +++ b/internal/handlers/upload_handler.go @@ -0,0 +1,96 @@ +package handlers + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + "github.com/treytartt/mycrib-api/internal/services" +) + +// UploadHandler handles file upload endpoints +type UploadHandler struct { + storageService *services.StorageService +} + +// NewUploadHandler creates a new upload handler +func NewUploadHandler(storageService *services.StorageService) *UploadHandler { + return &UploadHandler{storageService: storageService} +} + +// UploadImage handles POST /api/uploads/image +// Accepts multipart/form-data with "file" field +func (h *UploadHandler) UploadImage(c *gin.Context) { + file, err := c.FormFile("file") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "No file provided"}) + return + } + + // Get category from query param (default: images) + category := c.DefaultQuery("category", "images") + + result, err := h.storageService.Upload(file, category) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, result) +} + +// UploadDocument handles POST /api/uploads/document +// Accepts multipart/form-data with "file" field +func (h *UploadHandler) UploadDocument(c *gin.Context) { + file, err := c.FormFile("file") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "No file provided"}) + return + } + + result, err := h.storageService.Upload(file, "documents") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, result) +} + +// UploadCompletion handles POST /api/uploads/completion +// For task completion photos +func (h *UploadHandler) UploadCompletion(c *gin.Context) { + file, err := c.FormFile("file") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "No file provided"}) + return + } + + result, err := h.storageService.Upload(file, "completions") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, result) +} + +// DeleteFile handles DELETE /api/uploads +// Expects JSON body with "url" field +func (h *UploadHandler) DeleteFile(c *gin.Context) { + var req struct { + URL string `json:"url" binding:"required"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + if err := h.storageService.Delete(req.URL); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "File deleted successfully"}) +} diff --git a/internal/integration/integration_test.go b/internal/integration/integration_test.go new file mode 100644 index 0000000..fa9a4b1 --- /dev/null +++ b/internal/integration/integration_test.go @@ -0,0 +1,715 @@ +package integration + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/treytartt/mycrib-api/internal/config" + "github.com/treytartt/mycrib-api/internal/handlers" + "github.com/treytartt/mycrib-api/internal/middleware" + "github.com/treytartt/mycrib-api/internal/repositories" + "github.com/treytartt/mycrib-api/internal/services" + "github.com/treytartt/mycrib-api/internal/testutil" + "gorm.io/gorm" +) + +// TestApp holds all components for integration testing +type TestApp struct { + DB *gorm.DB + Router *gin.Engine + AuthHandler *handlers.AuthHandler + ResidenceHandler *handlers.ResidenceHandler + TaskHandler *handlers.TaskHandler + UserRepo *repositories.UserRepository + ResidenceRepo *repositories.ResidenceRepository + TaskRepo *repositories.TaskRepository + AuthService *services.AuthService +} + +func setupIntegrationTest(t *testing.T) *TestApp { + gin.SetMode(gin.TestMode) + + db := testutil.SetupTestDB(t) + testutil.SeedLookupData(t, db) + + // Create repositories + userRepo := repositories.NewUserRepository(db) + residenceRepo := repositories.NewResidenceRepository(db) + taskRepo := repositories.NewTaskRepository(db) + + // Create config + cfg := &config.Config{ + Security: config.SecurityConfig{ + SecretKey: "test-secret-key-for-integration-tests", + PasswordResetExpiry: 15 * time.Minute, + ConfirmationExpiry: 24 * time.Hour, + MaxPasswordResetRate: 3, + }, + } + + // Create services + authService := services.NewAuthService(userRepo, cfg) + residenceService := services.NewResidenceService(residenceRepo, userRepo, cfg) + taskService := services.NewTaskService(taskRepo, residenceRepo) + + // Create handlers + authHandler := handlers.NewAuthHandler(authService, nil, nil) + residenceHandler := handlers.NewResidenceHandler(residenceService, nil, nil) + taskHandler := handlers.NewTaskHandler(taskService, nil) + + // Create router with real middleware + router := gin.New() + + // Public routes + auth := router.Group("/api/auth") + { + auth.POST("/register", authHandler.Register) + auth.POST("/login", authHandler.Login) + } + + // Protected routes - use AuthMiddleware without Redis cache for testing + authMiddleware := middleware.NewAuthMiddleware(db, nil) + api := router.Group("/api") + api.Use(authMiddleware.TokenAuth()) + { + api.GET("/auth/me", authHandler.CurrentUser) + api.POST("/auth/logout", authHandler.Logout) + + residences := api.Group("/residences") + { + residences.GET("", residenceHandler.ListResidences) + residences.POST("", residenceHandler.CreateResidence) + residences.GET("/:id", residenceHandler.GetResidence) + residences.PUT("/:id", residenceHandler.UpdateResidence) + residences.DELETE("/:id", residenceHandler.DeleteResidence) + residences.POST("/:id/generate-share-code", residenceHandler.GenerateShareCode) + residences.GET("/:id/users", residenceHandler.GetResidenceUsers) + residences.DELETE("/:id/users/:userId", residenceHandler.RemoveResidenceUser) + } + api.POST("/residences/join-with-code", residenceHandler.JoinWithCode) + api.GET("/residence-types", residenceHandler.GetResidenceTypes) + + tasks := api.Group("/tasks") + { + tasks.GET("", taskHandler.ListTasks) + tasks.POST("", taskHandler.CreateTask) + tasks.GET("/:id", taskHandler.GetTask) + tasks.PUT("/:id", taskHandler.UpdateTask) + tasks.DELETE("/:id", taskHandler.DeleteTask) + tasks.POST("/:id/cancel", taskHandler.CancelTask) + tasks.POST("/:id/uncancel", taskHandler.UncancelTask) + tasks.POST("/:id/archive", taskHandler.ArchiveTask) + tasks.POST("/:id/unarchive", taskHandler.UnarchiveTask) + tasks.POST("/:id/mark-in-progress", taskHandler.MarkInProgress) + } + api.GET("/tasks/by-residence/:residence_id", taskHandler.GetTasksByResidence) + + completions := api.Group("/completions") + { + completions.GET("", taskHandler.ListCompletions) + completions.POST("", taskHandler.CreateCompletion) + completions.GET("/:id", taskHandler.GetCompletion) + completions.DELETE("/:id", taskHandler.DeleteCompletion) + } + + api.GET("/task-categories", taskHandler.GetCategories) + api.GET("/task-priorities", taskHandler.GetPriorities) + api.GET("/task-statuses", taskHandler.GetStatuses) + api.GET("/task-frequencies", taskHandler.GetFrequencies) + } + + return &TestApp{ + DB: db, + Router: router, + AuthHandler: authHandler, + ResidenceHandler: residenceHandler, + TaskHandler: taskHandler, + UserRepo: userRepo, + ResidenceRepo: residenceRepo, + TaskRepo: taskRepo, + AuthService: authService, + } +} + +// Helper to make authenticated requests +func (app *TestApp) makeAuthenticatedRequest(t *testing.T, method, path string, body interface{}, token string) *httptest.ResponseRecorder { + var reqBody []byte + var err error + if body != nil { + reqBody, err = json.Marshal(body) + require.NoError(t, err) + } + + req := httptest.NewRequest(method, path, bytes.NewBuffer(reqBody)) + req.Header.Set("Content-Type", "application/json") + if token != "" { + req.Header.Set("Authorization", "Token "+token) + } + + w := httptest.NewRecorder() + app.Router.ServeHTTP(w, req) + return w +} + +// Helper to register and login a user, returns token +func (app *TestApp) registerAndLogin(t *testing.T, username, email, password string) string { + // Register + registerBody := map[string]string{ + "username": username, + "email": email, + "password": password, + } + w := app.makeAuthenticatedRequest(t, "POST", "/api/auth/register", registerBody, "") + require.Equal(t, http.StatusCreated, w.Code) + + // Login + loginBody := map[string]string{ + "username": username, + "password": password, + } + w = app.makeAuthenticatedRequest(t, "POST", "/api/auth/login", loginBody, "") + require.Equal(t, http.StatusOK, w.Code) + + var loginResp map[string]interface{} + err := json.Unmarshal(w.Body.Bytes(), &loginResp) + require.NoError(t, err) + + return loginResp["token"].(string) +} + +// ============ Authentication Flow Tests ============ + +func TestIntegration_AuthenticationFlow(t *testing.T) { + app := setupIntegrationTest(t) + + // 1. Register a new user + registerBody := map[string]string{ + "username": "testuser", + "email": "test@example.com", + "password": "SecurePass123!", + } + w := app.makeAuthenticatedRequest(t, "POST", "/api/auth/register", registerBody, "") + assert.Equal(t, http.StatusCreated, w.Code) + + var registerResp map[string]interface{} + err := json.Unmarshal(w.Body.Bytes(), ®isterResp) + require.NoError(t, err) + assert.NotEmpty(t, registerResp["token"]) + assert.NotNil(t, registerResp["user"]) + + // 2. Login with the same credentials + loginBody := map[string]string{ + "username": "testuser", + "password": "SecurePass123!", + } + w = app.makeAuthenticatedRequest(t, "POST", "/api/auth/login", loginBody, "") + assert.Equal(t, http.StatusOK, w.Code) + + var loginResp map[string]interface{} + err = json.Unmarshal(w.Body.Bytes(), &loginResp) + require.NoError(t, err) + token := loginResp["token"].(string) + assert.NotEmpty(t, token) + + // 3. Get current user with token + w = app.makeAuthenticatedRequest(t, "GET", "/api/auth/me", nil, token) + assert.Equal(t, http.StatusOK, w.Code) + + var meResp map[string]interface{} + err = json.Unmarshal(w.Body.Bytes(), &meResp) + require.NoError(t, err) + assert.Equal(t, "testuser", meResp["username"]) + assert.Equal(t, "test@example.com", meResp["email"]) + + // 4. Access protected route without token should fail + w = app.makeAuthenticatedRequest(t, "GET", "/api/auth/me", nil, "") + assert.Equal(t, http.StatusUnauthorized, w.Code) + + // 5. Access protected route with invalid token should fail + w = app.makeAuthenticatedRequest(t, "GET", "/api/auth/me", nil, "invalid-token") + assert.Equal(t, http.StatusUnauthorized, w.Code) + + // 6. Logout + w = app.makeAuthenticatedRequest(t, "POST", "/api/auth/logout", nil, token) + assert.Equal(t, http.StatusOK, w.Code) +} + +func TestIntegration_RegistrationValidation(t *testing.T) { + app := setupIntegrationTest(t) + + tests := []struct { + name string + body map[string]string + expectedStatus int + }{ + { + name: "missing username", + body: map[string]string{"email": "test@example.com", "password": "pass123"}, + expectedStatus: http.StatusBadRequest, + }, + { + name: "missing email", + body: map[string]string{"username": "testuser", "password": "pass123"}, + expectedStatus: http.StatusBadRequest, + }, + { + name: "missing password", + body: map[string]string{"username": "testuser", "email": "test@example.com"}, + expectedStatus: http.StatusBadRequest, + }, + { + name: "invalid email", + body: map[string]string{"username": "testuser", "email": "invalid", "password": "pass123"}, + expectedStatus: http.StatusBadRequest, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + w := app.makeAuthenticatedRequest(t, "POST", "/api/auth/register", tt.body, "") + assert.Equal(t, tt.expectedStatus, w.Code) + }) + } +} + +func TestIntegration_DuplicateRegistration(t *testing.T) { + app := setupIntegrationTest(t) + + // Register first user (password must be >= 8 chars) + registerBody := map[string]string{ + "username": "testuser", + "email": "test@example.com", + "password": "password123", + } + w := app.makeAuthenticatedRequest(t, "POST", "/api/auth/register", registerBody, "") + assert.Equal(t, http.StatusCreated, w.Code) + + // Try to register with same username - returns 400 (BadRequest) + registerBody2 := map[string]string{ + "username": "testuser", + "email": "different@example.com", + "password": "password123", + } + w = app.makeAuthenticatedRequest(t, "POST", "/api/auth/register", registerBody2, "") + assert.Equal(t, http.StatusBadRequest, w.Code) + + // Try to register with same email - returns 400 (BadRequest) + registerBody3 := map[string]string{ + "username": "differentuser", + "email": "test@example.com", + "password": "password123", + } + w = app.makeAuthenticatedRequest(t, "POST", "/api/auth/register", registerBody3, "") + assert.Equal(t, http.StatusBadRequest, w.Code) +} + +// ============ Residence Flow Tests ============ + +func TestIntegration_ResidenceFlow(t *testing.T) { + app := setupIntegrationTest(t) + token := app.registerAndLogin(t, "owner", "owner@test.com", "password123") + + // 1. Create a residence + createBody := map[string]interface{}{ + "name": "My House", + "street_address": "123 Main St", + "city": "Austin", + "state_province": "TX", + "postal_code": "78701", + } + w := app.makeAuthenticatedRequest(t, "POST", "/api/residences", createBody, token) + assert.Equal(t, http.StatusCreated, w.Code) + + var createResp map[string]interface{} + err := json.Unmarshal(w.Body.Bytes(), &createResp) + require.NoError(t, err) + residenceID := createResp["id"].(float64) + assert.NotZero(t, residenceID) + assert.Equal(t, "My House", createResp["name"]) + assert.True(t, createResp["is_primary"].(bool)) + + // 2. Get the residence + w = app.makeAuthenticatedRequest(t, "GET", "/api/residences/"+formatID(residenceID), nil, token) + assert.Equal(t, http.StatusOK, w.Code) + + var getResp map[string]interface{} + err = json.Unmarshal(w.Body.Bytes(), &getResp) + require.NoError(t, err) + assert.Equal(t, "My House", getResp["name"]) + + // 3. List residences + w = app.makeAuthenticatedRequest(t, "GET", "/api/residences", nil, token) + assert.Equal(t, http.StatusOK, w.Code) + + var listResp []map[string]interface{} + err = json.Unmarshal(w.Body.Bytes(), &listResp) + require.NoError(t, err) + assert.Len(t, listResp, 1) + + // 4. Update the residence + updateBody := map[string]interface{}{ + "name": "My Updated House", + "city": "Dallas", + } + w = app.makeAuthenticatedRequest(t, "PUT", "/api/residences/"+formatID(residenceID), updateBody, token) + assert.Equal(t, http.StatusOK, w.Code) + + var updateResp map[string]interface{} + err = json.Unmarshal(w.Body.Bytes(), &updateResp) + require.NoError(t, err) + assert.Equal(t, "My Updated House", updateResp["name"]) + assert.Equal(t, "Dallas", updateResp["city"]) + + // 5. Delete the residence (returns 200 with message, not 204) + w = app.makeAuthenticatedRequest(t, "DELETE", "/api/residences/"+formatID(residenceID), nil, token) + assert.Equal(t, http.StatusOK, w.Code) + + // 6. Verify it's deleted (should return 403 - access denied since it doesn't exist/inactive) + w = app.makeAuthenticatedRequest(t, "GET", "/api/residences/"+formatID(residenceID), nil, token) + assert.Equal(t, http.StatusForbidden, w.Code) +} + +func TestIntegration_ResidenceSharingFlow(t *testing.T) { + app := setupIntegrationTest(t) + + // Create owner and another user + ownerToken := app.registerAndLogin(t, "owner", "owner@test.com", "password123") + userToken := app.registerAndLogin(t, "shareduser", "shared@test.com", "password123") + + // Create residence as owner + createBody := map[string]interface{}{ + "name": "Shared House", + } + w := app.makeAuthenticatedRequest(t, "POST", "/api/residences", createBody, ownerToken) + require.Equal(t, http.StatusCreated, w.Code) + + var createResp map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &createResp) + residenceID := createResp["id"].(float64) + + // Other user cannot access initially + w = app.makeAuthenticatedRequest(t, "GET", "/api/residences/"+formatID(residenceID), nil, userToken) + assert.Equal(t, http.StatusForbidden, w.Code) + + // Generate share code + w = app.makeAuthenticatedRequest(t, "POST", "/api/residences/"+formatID(residenceID)+"/generate-share-code", nil, ownerToken) + assert.Equal(t, http.StatusOK, w.Code) + + var shareResp map[string]interface{} + err := json.Unmarshal(w.Body.Bytes(), &shareResp) + require.NoError(t, err) + shareCodeObj, ok := shareResp["share_code"].(map[string]interface{}) + require.True(t, ok, "Expected share_code object in response") + shareCode := shareCodeObj["code"].(string) + assert.Len(t, shareCode, 6) + + // User joins with code + joinBody := map[string]interface{}{ + "code": shareCode, + } + w = app.makeAuthenticatedRequest(t, "POST", "/api/residences/join-with-code", joinBody, userToken) + assert.Equal(t, http.StatusOK, w.Code) + + // Now user can access + w = app.makeAuthenticatedRequest(t, "GET", "/api/residences/"+formatID(residenceID), nil, userToken) + assert.Equal(t, http.StatusOK, w.Code) + + // Get users list - returns array directly, not wrapped in {"users": ...} + w = app.makeAuthenticatedRequest(t, "GET", "/api/residences/"+formatID(residenceID)+"/users", nil, ownerToken) + assert.Equal(t, http.StatusOK, w.Code) + + var users []interface{} + err = json.Unmarshal(w.Body.Bytes(), &users) + require.NoError(t, err) + assert.Len(t, users, 2) // owner + shared user +} + +// ============ Task Flow Tests ============ + +func TestIntegration_TaskFlow(t *testing.T) { + app := setupIntegrationTest(t) + token := app.registerAndLogin(t, "owner", "owner@test.com", "password123") + + // Create residence first + residenceBody := map[string]interface{}{"name": "Task House"} + w := app.makeAuthenticatedRequest(t, "POST", "/api/residences", residenceBody, token) + require.Equal(t, http.StatusCreated, w.Code) + + var residenceResp map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &residenceResp) + residenceID := uint(residenceResp["id"].(float64)) + + // 1. Create a task + taskBody := map[string]interface{}{ + "residence_id": residenceID, + "title": "Fix leaky faucet", + "description": "Kitchen faucet is dripping", + } + w = app.makeAuthenticatedRequest(t, "POST", "/api/tasks", taskBody, token) + assert.Equal(t, http.StatusCreated, w.Code) + + var taskResp map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &taskResp) + taskID := taskResp["id"].(float64) + assert.NotZero(t, taskID) + assert.Equal(t, "Fix leaky faucet", taskResp["title"]) + + // 2. Get the task + w = app.makeAuthenticatedRequest(t, "GET", "/api/tasks/"+formatID(taskID), nil, token) + assert.Equal(t, http.StatusOK, w.Code) + + // 3. Update the task + updateBody := map[string]interface{}{ + "title": "Fix kitchen faucet", + "description": "Updated description", + } + w = app.makeAuthenticatedRequest(t, "PUT", "/api/tasks/"+formatID(taskID), updateBody, token) + assert.Equal(t, http.StatusOK, w.Code) + + var updateResp map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &updateResp) + assert.Equal(t, "Fix kitchen faucet", updateResp["title"]) + + // 4. Mark as in progress + w = app.makeAuthenticatedRequest(t, "POST", "/api/tasks/"+formatID(taskID)+"/mark-in-progress", nil, token) + assert.Equal(t, http.StatusOK, w.Code) + + var progressResp map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &progressResp) + task := progressResp["task"].(map[string]interface{}) + status := task["status"].(map[string]interface{}) + assert.Equal(t, "In Progress", status["name"]) + + // 5. Complete the task + completionBody := map[string]interface{}{ + "task_id": taskID, + "notes": "Fixed the faucet", + } + w = app.makeAuthenticatedRequest(t, "POST", "/api/completions", completionBody, token) + assert.Equal(t, http.StatusCreated, w.Code) + + var completionResp map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &completionResp) + completionID := completionResp["id"].(float64) + assert.NotZero(t, completionID) + assert.Equal(t, "Fixed the faucet", completionResp["notes"]) + + // 6. List completions + w = app.makeAuthenticatedRequest(t, "GET", "/api/completions?task_id="+formatID(taskID), nil, token) + assert.Equal(t, http.StatusOK, w.Code) + + // 7. Archive the task + w = app.makeAuthenticatedRequest(t, "POST", "/api/tasks/"+formatID(taskID)+"/archive", nil, token) + assert.Equal(t, http.StatusOK, w.Code) + + var archiveResp map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &archiveResp) + archivedTask := archiveResp["task"].(map[string]interface{}) + assert.True(t, archivedTask["is_archived"].(bool)) + + // 8. Unarchive the task + w = app.makeAuthenticatedRequest(t, "POST", "/api/tasks/"+formatID(taskID)+"/unarchive", nil, token) + assert.Equal(t, http.StatusOK, w.Code) + + // 9. Cancel the task + w = app.makeAuthenticatedRequest(t, "POST", "/api/tasks/"+formatID(taskID)+"/cancel", nil, token) + assert.Equal(t, http.StatusOK, w.Code) + + var cancelResp map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &cancelResp) + cancelledTask := cancelResp["task"].(map[string]interface{}) + assert.True(t, cancelledTask["is_cancelled"].(bool)) + + // 10. Delete the task (returns 200 with message, not 204) + w = app.makeAuthenticatedRequest(t, "DELETE", "/api/tasks/"+formatID(taskID), nil, token) + assert.Equal(t, http.StatusOK, w.Code) +} + +func TestIntegration_TasksByResidenceKanban(t *testing.T) { + app := setupIntegrationTest(t) + token := app.registerAndLogin(t, "owner", "owner@test.com", "password123") + + // Create residence + residenceBody := map[string]interface{}{"name": "Kanban House"} + w := app.makeAuthenticatedRequest(t, "POST", "/api/residences", residenceBody, token) + require.Equal(t, http.StatusCreated, w.Code) + + var residenceResp map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &residenceResp) + residenceID := uint(residenceResp["id"].(float64)) + + // Create multiple tasks + for i := 1; i <= 3; i++ { + taskBody := map[string]interface{}{ + "residence_id": residenceID, + "title": "Task " + formatID(float64(i)), + } + w = app.makeAuthenticatedRequest(t, "POST", "/api/tasks", taskBody, token) + require.Equal(t, http.StatusCreated, w.Code) + } + + // Get tasks by residence (kanban view) + w = app.makeAuthenticatedRequest(t, "GET", "/api/tasks/by-residence/"+formatID(float64(residenceID)), nil, token) + assert.Equal(t, http.StatusOK, w.Code) + + var kanbanResp map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &kanbanResp) + + columns := kanbanResp["columns"].([]interface{}) + assert.Greater(t, len(columns), 0) + + // Check column structure + for _, col := range columns { + column := col.(map[string]interface{}) + assert.NotEmpty(t, column["name"]) + assert.NotEmpty(t, column["display_name"]) + assert.NotNil(t, column["tasks"]) + assert.NotNil(t, column["count"]) + } +} + +// ============ Lookup Data Tests ============ + +func TestIntegration_LookupEndpoints(t *testing.T) { + app := setupIntegrationTest(t) + token := app.registerAndLogin(t, "user", "user@test.com", "password123") + + tests := []struct { + name string + endpoint string + }{ + {"residence types", "/api/residence-types"}, + {"task categories", "/api/task-categories"}, + {"task priorities", "/api/task-priorities"}, + {"task statuses", "/api/task-statuses"}, + {"task frequencies", "/api/task-frequencies"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + w := app.makeAuthenticatedRequest(t, "GET", tt.endpoint, nil, token) + assert.Equal(t, http.StatusOK, w.Code) + + // All lookup endpoints return arrays directly + var items []interface{} + err := json.Unmarshal(w.Body.Bytes(), &items) + require.NoError(t, err) + assert.Greater(t, len(items), 0) + + // Check item structure + for _, item := range items { + obj := item.(map[string]interface{}) + assert.NotZero(t, obj["id"]) + assert.NotEmpty(t, obj["name"]) + } + }) + } +} + +// ============ Access Control Tests ============ + +func TestIntegration_CrossUserAccessDenied(t *testing.T) { + app := setupIntegrationTest(t) + + // Create two users with their own residences + user1Token := app.registerAndLogin(t, "user1", "user1@test.com", "password123") + user2Token := app.registerAndLogin(t, "user2", "user2@test.com", "password123") + + // User1 creates a residence + residenceBody := map[string]interface{}{"name": "User1's House"} + w := app.makeAuthenticatedRequest(t, "POST", "/api/residences", residenceBody, user1Token) + require.Equal(t, http.StatusCreated, w.Code) + + var residenceResp map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &residenceResp) + residenceID := residenceResp["id"].(float64) + + // User1 creates a task + taskBody := map[string]interface{}{ + "residence_id": residenceID, + "title": "User1's Task", + } + w = app.makeAuthenticatedRequest(t, "POST", "/api/tasks", taskBody, user1Token) + require.Equal(t, http.StatusCreated, w.Code) + + var taskResp map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &taskResp) + taskID := taskResp["id"].(float64) + + // User2 cannot access User1's residence + w = app.makeAuthenticatedRequest(t, "GET", "/api/residences/"+formatID(residenceID), nil, user2Token) + assert.Equal(t, http.StatusForbidden, w.Code) + + // User2 cannot access User1's task + w = app.makeAuthenticatedRequest(t, "GET", "/api/tasks/"+formatID(taskID), nil, user2Token) + assert.Equal(t, http.StatusForbidden, w.Code) + + // User2 cannot update User1's residence + updateBody := map[string]interface{}{"name": "Hacked!"} + w = app.makeAuthenticatedRequest(t, "PUT", "/api/residences/"+formatID(residenceID), updateBody, user2Token) + assert.Equal(t, http.StatusForbidden, w.Code) + + // User2 cannot delete User1's residence + w = app.makeAuthenticatedRequest(t, "DELETE", "/api/residences/"+formatID(residenceID), nil, user2Token) + assert.Equal(t, http.StatusForbidden, w.Code) + + // User2 cannot create task in User1's residence + taskBody2 := map[string]interface{}{ + "residence_id": residenceID, + "title": "Malicious Task", + } + w = app.makeAuthenticatedRequest(t, "POST", "/api/tasks", taskBody2, user2Token) + assert.Equal(t, http.StatusForbidden, w.Code) +} + +// ============ JSON Response Structure Tests ============ + +func TestIntegration_ResponseStructure(t *testing.T) { + app := setupIntegrationTest(t) + token := app.registerAndLogin(t, "user", "user@test.com", "password123") + + // Create residence + residenceBody := map[string]interface{}{ + "name": "Response Test House", + "street_address": "123 Test St", + "city": "Austin", + "state_province": "TX", + "postal_code": "78701", + } + w := app.makeAuthenticatedRequest(t, "POST", "/api/residences", residenceBody, token) + require.Equal(t, http.StatusCreated, w.Code) + + var resp map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &resp) + + // Verify all expected fields are present + expectedFields := []string{ + "id", "owner_id", "name", "street_address", "city", + "state_province", "postal_code", "country", + "is_primary", "is_active", "created_at", "updated_at", + } + + for _, field := range expectedFields { + _, exists := resp[field] + assert.True(t, exists, "Expected field %s to be present", field) + } + + // Check that nullable fields can be null + assert.Nil(t, resp["bedrooms"]) + assert.Nil(t, resp["bathrooms"]) +} + +// ============ Helper Functions ============ + +func formatID(id float64) string { + return fmt.Sprintf("%d", uint(id)) +} diff --git a/internal/middleware/admin_auth.go b/internal/middleware/admin_auth.go new file mode 100644 index 0000000..e003dc4 --- /dev/null +++ b/internal/middleware/admin_auth.go @@ -0,0 +1,129 @@ +package middleware + +import ( + "errors" + "net/http" + "strings" + "time" + + "github.com/gin-gonic/gin" + "github.com/golang-jwt/jwt/v5" + + "github.com/treytartt/mycrib-api/internal/config" + "github.com/treytartt/mycrib-api/internal/models" + "github.com/treytartt/mycrib-api/internal/repositories" +) + +const ( + // AdminUserKey is the context key for the authenticated admin user + AdminUserKey = "admin_user" + // AdminClaimsKey is the context key for JWT claims + AdminClaimsKey = "admin_claims" +) + +// AdminClaims represents the JWT claims for admin authentication +type AdminClaims struct { + AdminID uint `json:"admin_id"` + Email string `json:"email"` + Role models.AdminRole `json:"role"` + jwt.RegisteredClaims +} + +// AdminAuthMiddleware creates a middleware that validates admin JWT tokens +func AdminAuthMiddleware(cfg *config.Config, adminRepo *repositories.AdminRepository) gin.HandlerFunc { + return func(c *gin.Context) { + // Get token from Authorization header + authHeader := c.GetHeader("Authorization") + if authHeader == "" { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Authorization header required"}) + return + } + + // Check Bearer prefix + parts := strings.SplitN(authHeader, " ", 2) + if len(parts) != 2 || strings.ToLower(parts[0]) != "bearer" { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Invalid authorization header format"}) + return + } + + tokenString := parts[1] + + // Parse and validate token + claims := &AdminClaims{} + token, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) { + // Validate signing method + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, errors.New("invalid signing method") + } + return []byte(cfg.Security.SecretKey), nil + }) + + if err != nil { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Invalid token"}) + return + } + + if !token.Valid { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Token is not valid"}) + return + } + + // Get admin user from database + admin, err := adminRepo.FindByID(claims.AdminID) + if err != nil { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Admin user not found"}) + return + } + + // Check if admin is active + if !admin.IsActive { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Admin account is disabled"}) + return + } + + // Store admin and claims in context + c.Set(AdminUserKey, admin) + c.Set(AdminClaimsKey, claims) + + c.Next() + } +} + +// GenerateAdminToken creates a new JWT token for an admin user +func GenerateAdminToken(admin *models.AdminUser, cfg *config.Config) (string, error) { + // Token expires in 24 hours + expirationTime := time.Now().Add(24 * time.Hour) + + claims := &AdminClaims{ + AdminID: admin.ID, + Email: admin.Email, + Role: admin.Role, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(expirationTime), + IssuedAt: jwt.NewNumericDate(time.Now()), + Subject: admin.Email, + }, + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return token.SignedString([]byte(cfg.Security.SecretKey)) +} + +// RequireSuperAdmin middleware requires the admin to have super_admin role +func RequireSuperAdmin() gin.HandlerFunc { + return func(c *gin.Context) { + admin, exists := c.Get(AdminUserKey) + if !exists { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Admin authentication required"}) + return + } + + adminUser := admin.(*models.AdminUser) + if !adminUser.IsSuperAdmin() { + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "Super admin privileges required"}) + return + } + + c.Next() + } +} diff --git a/internal/models/admin.go b/internal/models/admin.go new file mode 100644 index 0000000..f7d913b --- /dev/null +++ b/internal/models/admin.go @@ -0,0 +1,63 @@ +package models + +import ( + "time" + + "golang.org/x/crypto/bcrypt" +) + +// AdminRole represents the role of an admin user +type AdminRole string + +const ( + AdminRoleAdmin AdminRole = "admin" + AdminRoleSuperAdmin AdminRole = "super_admin" +) + +// AdminUser represents an administrator for the admin panel +type AdminUser struct { + ID uint `gorm:"primaryKey" json:"id"` + Email string `gorm:"uniqueIndex;size:254;not null" json:"email"` + Password string `gorm:"size:128;not null" json:"-"` + FirstName string `gorm:"size:100" json:"first_name"` + LastName string `gorm:"size:100" json:"last_name"` + Role AdminRole `gorm:"size:20;default:'admin'" json:"role"` + IsActive bool `gorm:"default:true" json:"is_active"` + LastLogin *time.Time `json:"last_login,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// TableName specifies the table name for AdminUser +func (AdminUser) TableName() string { + return "admin_users" +} + +// SetPassword hashes and sets the password +func (a *AdminUser) SetPassword(password string) error { + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return err + } + a.Password = string(hash) + return nil +} + +// CheckPassword verifies the password against the stored hash +func (a *AdminUser) CheckPassword(password string) bool { + err := bcrypt.CompareHashAndPassword([]byte(a.Password), []byte(password)) + return err == nil +} + +// FullName returns the admin's full name +func (a *AdminUser) FullName() string { + if a.FirstName == "" && a.LastName == "" { + return a.Email + } + return a.FirstName + " " + a.LastName +} + +// IsSuperAdmin checks if the admin has super admin privileges +func (a *AdminUser) IsSuperAdmin() bool { + return a.Role == AdminRoleSuperAdmin +} diff --git a/internal/models/residence_test.go b/internal/models/residence_test.go new file mode 100644 index 0000000..4621dc1 --- /dev/null +++ b/internal/models/residence_test.go @@ -0,0 +1,113 @@ +package models + +import ( + "encoding/json" + "testing" + + "github.com/shopspring/decimal" + "github.com/stretchr/testify/assert" +) + +func TestResidence_TableName(t *testing.T) { + r := Residence{} + assert.Equal(t, "residence_residence", r.TableName()) +} + +func TestResidenceType_TableName(t *testing.T) { + rt := ResidenceType{} + assert.Equal(t, "residence_residencetype", rt.TableName()) +} + +func TestResidenceShareCode_TableName(t *testing.T) { + sc := ResidenceShareCode{} + assert.Equal(t, "residence_residencesharecode", sc.TableName()) +} + +func TestResidence_JSONSerialization(t *testing.T) { + bedrooms := 3 + bathrooms := decimal.NewFromFloat(2.5) + sqft := 2000 + yearBuilt := 2020 + + residence := Residence{ + Name: "Test House", + StreetAddress: "123 Main St", + City: "Austin", + StateProvince: "TX", + PostalCode: "78701", + Country: "USA", + Bedrooms: &bedrooms, + Bathrooms: &bathrooms, + SquareFootage: &sqft, + YearBuilt: &yearBuilt, + IsActive: true, + IsPrimary: true, + } + residence.ID = 1 + + data, err := json.Marshal(residence) + assert.NoError(t, err) + + var result map[string]interface{} + err = json.Unmarshal(data, &result) + assert.NoError(t, err) + + // Check JSON field names + assert.Equal(t, float64(1), result["id"]) + assert.Equal(t, "Test House", result["name"]) + assert.Equal(t, "123 Main St", result["street_address"]) + assert.Equal(t, "Austin", result["city"]) + assert.Equal(t, "TX", result["state_province"]) + assert.Equal(t, "78701", result["postal_code"]) + assert.Equal(t, "USA", result["country"]) + assert.Equal(t, float64(3), result["bedrooms"]) + assert.Equal(t, "2.5", result["bathrooms"]) // Decimal serializes as string + assert.Equal(t, float64(2000), result["square_footage"]) + assert.Equal(t, float64(2020), result["year_built"]) + assert.Equal(t, true, result["is_active"]) + assert.Equal(t, true, result["is_primary"]) +} + +func TestResidenceType_JSONSerialization(t *testing.T) { + rt := ResidenceType{ + Name: "House", + } + rt.ID = 1 + + data, err := json.Marshal(rt) + assert.NoError(t, err) + + var result map[string]interface{} + err = json.Unmarshal(data, &result) + assert.NoError(t, err) + + assert.Equal(t, float64(1), result["id"]) + assert.Equal(t, "House", result["name"]) +} + +func TestResidence_NilOptionalFields(t *testing.T) { + residence := Residence{ + Name: "Test House", + StreetAddress: "123 Main St", + City: "Austin", + StateProvince: "TX", + PostalCode: "78701", + Country: "USA", + IsActive: true, + IsPrimary: false, + // All optional fields are nil + } + + data, err := json.Marshal(residence) + assert.NoError(t, err) + + var result map[string]interface{} + err = json.Unmarshal(data, &result) + assert.NoError(t, err) + + // Nil pointer fields should serialize as null + assert.Nil(t, result["bedrooms"]) + assert.Nil(t, result["bathrooms"]) + assert.Nil(t, result["square_footage"]) + assert.Nil(t, result["year_built"]) +} diff --git a/internal/models/subscription.go b/internal/models/subscription.go index df39164..7817071 100644 --- a/internal/models/subscription.go +++ b/internal/models/subscription.go @@ -27,6 +27,7 @@ func (SubscriptionSettings) TableName() string { type UserSubscription struct { BaseModel UserID uint `gorm:"column:user_id;uniqueIndex;not null" json:"user_id"` + User User `gorm:"foreignKey:UserID" json:"-"` Tier SubscriptionTier `gorm:"column:tier;size:10;default:'free'" json:"tier"` // In-App Purchase data diff --git a/internal/models/task_test.go b/internal/models/task_test.go new file mode 100644 index 0000000..45577c0 --- /dev/null +++ b/internal/models/task_test.go @@ -0,0 +1,275 @@ +package models + +import ( + "encoding/json" + "testing" + "time" + + "github.com/shopspring/decimal" + "github.com/stretchr/testify/assert" +) + +func TestTask_TableName(t *testing.T) { + task := Task{} + assert.Equal(t, "task_task", task.TableName()) +} + +func TestTaskCategory_TableName(t *testing.T) { + cat := TaskCategory{} + assert.Equal(t, "task_taskcategory", cat.TableName()) +} + +func TestTaskPriority_TableName(t *testing.T) { + p := TaskPriority{} + assert.Equal(t, "task_taskpriority", p.TableName()) +} + +func TestTaskStatus_TableName(t *testing.T) { + s := TaskStatus{} + assert.Equal(t, "task_taskstatus", s.TableName()) +} + +func TestTaskFrequency_TableName(t *testing.T) { + f := TaskFrequency{} + assert.Equal(t, "task_taskfrequency", f.TableName()) +} + +func TestTaskCompletion_TableName(t *testing.T) { + c := TaskCompletion{} + assert.Equal(t, "task_taskcompletion", c.TableName()) +} + +func TestContractor_TableName(t *testing.T) { + c := Contractor{} + assert.Equal(t, "task_contractor", c.TableName()) +} + +func TestContractorSpecialty_TableName(t *testing.T) { + s := ContractorSpecialty{} + assert.Equal(t, "task_contractorspecialty", s.TableName()) +} + +func TestDocument_TableName(t *testing.T) { + d := Document{} + assert.Equal(t, "task_document", d.TableName()) +} + +func TestTask_JSONSerialization(t *testing.T) { + dueDate := time.Date(2024, 12, 31, 0, 0, 0, 0, time.UTC) + cost := decimal.NewFromFloat(150.50) + + task := Task{ + ResidenceID: 1, + CreatedByID: 1, + Title: "Fix leaky faucet", + Description: "Kitchen faucet is dripping", + DueDate: &dueDate, + EstimatedCost: &cost, + IsCancelled: false, + IsArchived: false, + } + task.ID = 1 + + data, err := json.Marshal(task) + assert.NoError(t, err) + + var result map[string]interface{} + err = json.Unmarshal(data, &result) + assert.NoError(t, err) + + assert.Equal(t, float64(1), result["id"]) + assert.Equal(t, float64(1), result["residence_id"]) + assert.Equal(t, float64(1), result["created_by_id"]) + assert.Equal(t, "Fix leaky faucet", result["title"]) + assert.Equal(t, "Kitchen faucet is dripping", result["description"]) + assert.Equal(t, "150.5", result["estimated_cost"]) // Decimal serializes as string + assert.Equal(t, false, result["is_cancelled"]) + assert.Equal(t, false, result["is_archived"]) +} + +func TestTaskCategory_JSONSerialization(t *testing.T) { + cat := TaskCategory{ + Name: "Plumbing", + Description: "Plumbing related tasks", + Icon: "wrench", + Color: "#3498db", + DisplayOrder: 1, + } + cat.ID = 1 + + data, err := json.Marshal(cat) + assert.NoError(t, err) + + var result map[string]interface{} + err = json.Unmarshal(data, &result) + assert.NoError(t, err) + + assert.Equal(t, float64(1), result["id"]) + assert.Equal(t, "Plumbing", result["name"]) + assert.Equal(t, "Plumbing related tasks", result["description"]) + assert.Equal(t, "wrench", result["icon"]) + assert.Equal(t, "#3498db", result["color"]) + assert.Equal(t, float64(1), result["display_order"]) +} + +func TestTaskPriority_JSONSerialization(t *testing.T) { + priority := TaskPriority{ + Name: "High", + Level: 3, + Color: "#e74c3c", + DisplayOrder: 3, + } + priority.ID = 3 + + data, err := json.Marshal(priority) + assert.NoError(t, err) + + var result map[string]interface{} + err = json.Unmarshal(data, &result) + assert.NoError(t, err) + + assert.Equal(t, float64(3), result["id"]) + assert.Equal(t, "High", result["name"]) + assert.Equal(t, float64(3), result["level"]) + assert.Equal(t, "#e74c3c", result["color"]) +} + +func TestTaskStatus_JSONSerialization(t *testing.T) { + status := TaskStatus{ + Name: "In Progress", + Description: "Task is being worked on", + Color: "#3498db", + DisplayOrder: 2, + } + status.ID = 2 + + data, err := json.Marshal(status) + assert.NoError(t, err) + + var result map[string]interface{} + err = json.Unmarshal(data, &result) + assert.NoError(t, err) + + assert.Equal(t, float64(2), result["id"]) + assert.Equal(t, "In Progress", result["name"]) + assert.Equal(t, "Task is being worked on", result["description"]) + assert.Equal(t, "#3498db", result["color"]) +} + +func TestTaskFrequency_JSONSerialization(t *testing.T) { + days := 7 + freq := TaskFrequency{ + Name: "Weekly", + Days: &days, + DisplayOrder: 3, + } + freq.ID = 3 + + data, err := json.Marshal(freq) + assert.NoError(t, err) + + var result map[string]interface{} + err = json.Unmarshal(data, &result) + assert.NoError(t, err) + + assert.Equal(t, float64(3), result["id"]) + assert.Equal(t, "Weekly", result["name"]) + assert.Equal(t, float64(7), result["days"]) +} + +func TestTaskCompletion_JSONSerialization(t *testing.T) { + completedAt := time.Date(2024, 6, 15, 14, 30, 0, 0, time.UTC) + cost := decimal.NewFromFloat(125.00) + + completion := TaskCompletion{ + TaskID: 1, + CompletedByID: 2, + CompletedAt: completedAt, + Notes: "Fixed the leak", + ActualCost: &cost, + } + completion.ID = 1 + + data, err := json.Marshal(completion) + assert.NoError(t, err) + + var result map[string]interface{} + err = json.Unmarshal(data, &result) + assert.NoError(t, err) + + assert.Equal(t, float64(1), result["id"]) + assert.Equal(t, float64(1), result["task_id"]) + assert.Equal(t, float64(2), result["completed_by_id"]) + assert.Equal(t, "Fixed the leak", result["notes"]) + assert.Equal(t, "125", result["actual_cost"]) // Decimal serializes as string +} + +func TestContractor_JSONSerialization(t *testing.T) { + contractor := Contractor{ + ResidenceID: 1, + CreatedByID: 1, + Name: "Mike's Plumbing", + Company: "Mike's Plumbing Co.", + Phone: "+1-555-1234", + Email: "mike@plumbing.com", + Website: "https://mikesplumbing.com", + Notes: "Great service", + IsFavorite: true, + IsActive: true, + } + contractor.ID = 1 + + data, err := json.Marshal(contractor) + assert.NoError(t, err) + + var result map[string]interface{} + err = json.Unmarshal(data, &result) + assert.NoError(t, err) + + assert.Equal(t, float64(1), result["id"]) + assert.Equal(t, float64(1), result["residence_id"]) + assert.Equal(t, "Mike's Plumbing", result["name"]) + assert.Equal(t, "Mike's Plumbing Co.", result["company"]) + assert.Equal(t, "+1-555-1234", result["phone"]) + assert.Equal(t, "mike@plumbing.com", result["email"]) + assert.Equal(t, "https://mikesplumbing.com", result["website"]) + assert.Equal(t, true, result["is_favorite"]) + assert.Equal(t, true, result["is_active"]) +} + +func TestDocument_JSONSerialization(t *testing.T) { + purchaseDate := time.Date(2023, 6, 15, 0, 0, 0, 0, time.UTC) + expiryDate := time.Date(2028, 6, 15, 0, 0, 0, 0, time.UTC) + price := decimal.NewFromFloat(5000.00) + + doc := Document{ + ResidenceID: 1, + CreatedByID: 1, + Title: "HVAC Warranty", + Description: "Warranty for central air", + DocumentType: "warranty", + FileURL: "/uploads/hvac.pdf", + FileName: "hvac.pdf", + PurchaseDate: &purchaseDate, + ExpiryDate: &expiryDate, + PurchasePrice: &price, + Vendor: "Cool Air HVAC", + SerialNumber: "HVAC-123", + } + doc.ID = 1 + + data, err := json.Marshal(doc) + assert.NoError(t, err) + + var result map[string]interface{} + err = json.Unmarshal(data, &result) + assert.NoError(t, err) + + assert.Equal(t, float64(1), result["id"]) + assert.Equal(t, "HVAC Warranty", result["title"]) + assert.Equal(t, "warranty", result["document_type"]) + assert.Equal(t, "/uploads/hvac.pdf", result["file_url"]) + assert.Equal(t, "Cool Air HVAC", result["vendor"]) + assert.Equal(t, "HVAC-123", result["serial_number"]) + assert.Equal(t, "5000", result["purchase_price"]) // Decimal serializes as string +} diff --git a/internal/models/user_test.go b/internal/models/user_test.go new file mode 100644 index 0000000..8403153 --- /dev/null +++ b/internal/models/user_test.go @@ -0,0 +1,217 @@ +package models + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestUser_SetPassword(t *testing.T) { + user := &User{} + + err := user.SetPassword("testpassword123") + require.NoError(t, err) + assert.NotEmpty(t, user.Password) + assert.NotEqual(t, "testpassword123", user.Password) // Should be hashed +} + +func TestUser_CheckPassword(t *testing.T) { + user := &User{} + err := user.SetPassword("correctpassword") + require.NoError(t, err) + + tests := []struct { + name string + password string + expected bool + }{ + {"correct password", "correctpassword", true}, + {"wrong password", "wrongpassword", false}, + {"empty password", "", false}, + {"similar password", "correctpassword1", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := user.CheckPassword(tt.password) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestUser_GetFullName(t *testing.T) { + tests := []struct { + name string + user User + expected string + }{ + { + name: "first and last name", + user: User{FirstName: "John", LastName: "Doe", Username: "johndoe"}, + expected: "John Doe", + }, + { + name: "first name only", + user: User{FirstName: "John", LastName: "", Username: "johndoe"}, + expected: "John", + }, + { + name: "username fallback", + user: User{FirstName: "", LastName: "", Username: "johndoe"}, + expected: "johndoe", + }, + { + name: "last name only returns username", + user: User{FirstName: "", LastName: "Doe", Username: "johndoe"}, + expected: "johndoe", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := tt.user.GetFullName() + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestUser_TableName(t *testing.T) { + user := User{} + assert.Equal(t, "auth_user", user.TableName()) +} + +func TestAuthToken_TableName(t *testing.T) { + token := AuthToken{} + assert.Equal(t, "user_authtoken", token.TableName()) +} + +func TestUserProfile_TableName(t *testing.T) { + profile := UserProfile{} + assert.Equal(t, "user_userprofile", profile.TableName()) +} + +func TestConfirmationCode_IsValid(t *testing.T) { + now := time.Now().UTC() + future := now.Add(1 * time.Hour) + past := now.Add(-1 * time.Hour) + + tests := []struct { + name string + code ConfirmationCode + expected bool + }{ + { + name: "valid code", + code: ConfirmationCode{IsUsed: false, ExpiresAt: future}, + expected: true, + }, + { + name: "used code", + code: ConfirmationCode{IsUsed: true, ExpiresAt: future}, + expected: false, + }, + { + name: "expired code", + code: ConfirmationCode{IsUsed: false, ExpiresAt: past}, + expected: false, + }, + { + name: "used and expired", + code: ConfirmationCode{IsUsed: true, ExpiresAt: past}, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := tt.code.IsValid() + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestPasswordResetCode_IsValid(t *testing.T) { + now := time.Now().UTC() + future := now.Add(1 * time.Hour) + past := now.Add(-1 * time.Hour) + + tests := []struct { + name string + code PasswordResetCode + expected bool + }{ + { + name: "valid code", + code: PasswordResetCode{Used: false, ExpiresAt: future, Attempts: 0, MaxAttempts: 5}, + expected: true, + }, + { + name: "used code", + code: PasswordResetCode{Used: true, ExpiresAt: future, Attempts: 0, MaxAttempts: 5}, + expected: false, + }, + { + name: "expired code", + code: PasswordResetCode{Used: false, ExpiresAt: past, Attempts: 0, MaxAttempts: 5}, + expected: false, + }, + { + name: "max attempts reached", + code: PasswordResetCode{Used: false, ExpiresAt: future, Attempts: 5, MaxAttempts: 5}, + expected: false, + }, + { + name: "attempts under max", + code: PasswordResetCode{Used: false, ExpiresAt: future, Attempts: 4, MaxAttempts: 5}, + expected: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := tt.code.IsValid() + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestPasswordResetCode_SetAndCheckCode(t *testing.T) { + code := &PasswordResetCode{} + + err := code.SetCode("123456") + require.NoError(t, err) + assert.NotEmpty(t, code.CodeHash) + + // Check correct code + assert.True(t, code.CheckCode("123456")) + + // Check wrong code + assert.False(t, code.CheckCode("654321")) + assert.False(t, code.CheckCode("")) +} + +func TestGenerateConfirmationCode(t *testing.T) { + code := GenerateConfirmationCode() + assert.Len(t, code, 6) + + // Generate multiple codes and ensure they're different + codes := make(map[string]bool) + for i := 0; i < 10; i++ { + c := GenerateConfirmationCode() + assert.Len(t, c, 6) + codes[c] = true + } + // Most codes should be unique (very unlikely to have collisions) + assert.Greater(t, len(codes), 5) +} + +func TestGenerateResetToken(t *testing.T) { + token := GenerateResetToken() + assert.Len(t, token, 64) // 32 bytes = 64 hex chars + + // Ensure uniqueness + token2 := GenerateResetToken() + assert.NotEqual(t, token, token2) +} diff --git a/internal/repositories/admin_repo.go b/internal/repositories/admin_repo.go new file mode 100644 index 0000000..b097364 --- /dev/null +++ b/internal/repositories/admin_repo.go @@ -0,0 +1,107 @@ +package repositories + +import ( + "errors" + "time" + + "gorm.io/gorm" + + "github.com/treytartt/mycrib-api/internal/models" +) + +var ( + ErrAdminNotFound = errors.New("admin user not found") + ErrAdminExists = errors.New("admin user already exists") +) + +// AdminRepository handles admin user database operations +type AdminRepository struct { + db *gorm.DB +} + +// NewAdminRepository creates a new admin repository +func NewAdminRepository(db *gorm.DB) *AdminRepository { + return &AdminRepository{db: db} +} + +// FindByID finds an admin user by ID +func (r *AdminRepository) FindByID(id uint) (*models.AdminUser, error) { + var admin models.AdminUser + if err := r.db.First(&admin, id).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrAdminNotFound + } + return nil, err + } + return &admin, nil +} + +// FindByEmail finds an admin user by email (case-insensitive) +func (r *AdminRepository) FindByEmail(email string) (*models.AdminUser, error) { + var admin models.AdminUser + if err := r.db.Where("LOWER(email) = LOWER(?)", email).First(&admin).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrAdminNotFound + } + return nil, err + } + return &admin, nil +} + +// Create creates a new admin user +func (r *AdminRepository) Create(admin *models.AdminUser) error { + // Check if email already exists + var count int64 + if err := r.db.Model(&models.AdminUser{}).Where("LOWER(email) = LOWER(?)", admin.Email).Count(&count).Error; err != nil { + return err + } + if count > 0 { + return ErrAdminExists + } + + return r.db.Create(admin).Error +} + +// Update updates an admin user +func (r *AdminRepository) Update(admin *models.AdminUser) error { + return r.db.Save(admin).Error +} + +// Delete deletes an admin user +func (r *AdminRepository) Delete(id uint) error { + return r.db.Delete(&models.AdminUser{}, id).Error +} + +// UpdateLastLogin updates the last login timestamp +func (r *AdminRepository) UpdateLastLogin(id uint) error { + now := time.Now() + return r.db.Model(&models.AdminUser{}).Where("id = ?", id).Update("last_login", now).Error +} + +// List returns all admin users with pagination +func (r *AdminRepository) List(page, pageSize int) ([]models.AdminUser, int64, error) { + var admins []models.AdminUser + var total int64 + + // Get total count + if err := r.db.Model(&models.AdminUser{}).Count(&total).Error; err != nil { + return nil, 0, err + } + + // Get paginated results + offset := (page - 1) * pageSize + if err := r.db.Order("created_at DESC").Offset(offset).Limit(pageSize).Find(&admins).Error; err != nil { + return nil, 0, err + } + + return admins, total, nil +} + +// ExistsByEmail checks if an admin user with the given email exists +func (r *AdminRepository) ExistsByEmail(email string) (bool, error) { + var count int64 + if err := r.db.Model(&models.AdminUser{}).Where("LOWER(email) = LOWER(?)", email).Count(&count).Error; err != nil { + return false, err + } + return count > 0, nil +} diff --git a/internal/repositories/residence_repo_test.go b/internal/repositories/residence_repo_test.go new file mode 100644 index 0000000..128565f --- /dev/null +++ b/internal/repositories/residence_repo_test.go @@ -0,0 +1,330 @@ +package repositories + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/treytartt/mycrib-api/internal/models" + "github.com/treytartt/mycrib-api/internal/testutil" +) + +func TestResidenceRepository_Create(t *testing.T) { + db := testutil.SetupTestDB(t) + repo := NewResidenceRepository(db) + + user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password") + + residence := &models.Residence{ + OwnerID: user.ID, + Name: "Test House", + StreetAddress: "123 Main St", + City: "Austin", + StateProvince: "TX", + PostalCode: "78701", + Country: "USA", + IsActive: true, + IsPrimary: true, + } + + err := repo.Create(residence) + require.NoError(t, err) + assert.NotZero(t, residence.ID) +} + +func TestResidenceRepository_FindByID(t *testing.T) { + db := testutil.SetupTestDB(t) + repo := NewResidenceRepository(db) + + user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password") + residence := testutil.CreateTestResidence(t, db, user.ID, "Test House") + + found, err := repo.FindByID(residence.ID) + require.NoError(t, err) + assert.Equal(t, residence.ID, found.ID) + assert.Equal(t, "Test House", found.Name) + assert.Equal(t, user.ID, found.OwnerID) +} + +func TestResidenceRepository_FindByID_NotFound(t *testing.T) { + db := testutil.SetupTestDB(t) + repo := NewResidenceRepository(db) + + _, err := repo.FindByID(9999) + assert.Error(t, err) +} + +func TestResidenceRepository_FindByID_InactiveNotFound(t *testing.T) { + db := testutil.SetupTestDB(t) + repo := NewResidenceRepository(db) + + user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password") + residence := testutil.CreateTestResidence(t, db, user.ID, "Test House") + + // Deactivate + db.Model(residence).Update("is_active", false) + + _, err := repo.FindByID(residence.ID) + assert.Error(t, err) // Should not find inactive residences +} + +func TestResidenceRepository_FindByUser(t *testing.T) { + db := testutil.SetupTestDB(t) + repo := NewResidenceRepository(db) + + owner := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password") + sharedUser := testutil.CreateTestUser(t, db, "shared", "shared@test.com", "password") + + // Create residences + r1 := testutil.CreateTestResidence(t, db, owner.ID, "House 1") + r2 := testutil.CreateTestResidence(t, db, owner.ID, "House 2") + + // Share r1 with sharedUser + repo.AddUser(r1.ID, sharedUser.ID) + + // Owner should see both + ownerResidences, err := repo.FindByUser(owner.ID) + require.NoError(t, err) + assert.Len(t, ownerResidences, 2) + + // Shared user should see only r1 + sharedResidences, err := repo.FindByUser(sharedUser.ID) + require.NoError(t, err) + assert.Len(t, sharedResidences, 1) + assert.Equal(t, r1.ID, sharedResidences[0].ID) + + // Another user should see nothing + otherUser := testutil.CreateTestUser(t, db, "other", "other@test.com", "password") + otherResidences, err := repo.FindByUser(otherUser.ID) + require.NoError(t, err) + assert.Len(t, otherResidences, 0) + + _ = r2 // suppress unused +} + +func TestResidenceRepository_FindOwnedByUser(t *testing.T) { + db := testutil.SetupTestDB(t) + repo := NewResidenceRepository(db) + + owner := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password") + otherOwner := testutil.CreateTestUser(t, db, "other", "other@test.com", "password") + + testutil.CreateTestResidence(t, db, owner.ID, "House 1") + testutil.CreateTestResidence(t, db, owner.ID, "House 2") + testutil.CreateTestResidence(t, db, otherOwner.ID, "Other House") + + residences, err := repo.FindOwnedByUser(owner.ID) + require.NoError(t, err) + assert.Len(t, residences, 2) +} + +func TestResidenceRepository_Update(t *testing.T) { + db := testutil.SetupTestDB(t) + repo := NewResidenceRepository(db) + + user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password") + residence := testutil.CreateTestResidence(t, db, user.ID, "Test House") + + residence.Name = "Updated House" + residence.City = "Dallas" + err := repo.Update(residence) + require.NoError(t, err) + + found, err := repo.FindByID(residence.ID) + require.NoError(t, err) + assert.Equal(t, "Updated House", found.Name) + assert.Equal(t, "Dallas", found.City) +} + +func TestResidenceRepository_Delete(t *testing.T) { + db := testutil.SetupTestDB(t) + repo := NewResidenceRepository(db) + + user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password") + residence := testutil.CreateTestResidence(t, db, user.ID, "Test House") + + err := repo.Delete(residence.ID) + require.NoError(t, err) + + // Should not be found (soft delete sets is_active = false) + _, err = repo.FindByID(residence.ID) + assert.Error(t, err) +} + +func TestResidenceRepository_HasAccess(t *testing.T) { + db := testutil.SetupTestDB(t) + repo := NewResidenceRepository(db) + + owner := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password") + sharedUser := testutil.CreateTestUser(t, db, "shared", "shared@test.com", "password") + otherUser := testutil.CreateTestUser(t, db, "other", "other@test.com", "password") + + residence := testutil.CreateTestResidence(t, db, owner.ID, "Test House") + repo.AddUser(residence.ID, sharedUser.ID) + + tests := []struct { + name string + userID uint + expected bool + }{ + {"owner has access", owner.ID, true}, + {"shared user has access", sharedUser.ID, true}, + {"other user no access", otherUser.ID, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + hasAccess, err := repo.HasAccess(residence.ID, tt.userID) + require.NoError(t, err) + assert.Equal(t, tt.expected, hasAccess) + }) + } +} + +func TestResidenceRepository_IsOwner(t *testing.T) { + db := testutil.SetupTestDB(t) + repo := NewResidenceRepository(db) + + owner := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password") + sharedUser := testutil.CreateTestUser(t, db, "shared", "shared@test.com", "password") + + residence := testutil.CreateTestResidence(t, db, owner.ID, "Test House") + repo.AddUser(residence.ID, sharedUser.ID) + + // Owner should be owner + isOwner, err := repo.IsOwner(residence.ID, owner.ID) + require.NoError(t, err) + assert.True(t, isOwner) + + // Shared user should not be owner + isOwner, err = repo.IsOwner(residence.ID, sharedUser.ID) + require.NoError(t, err) + assert.False(t, isOwner) +} + +func TestResidenceRepository_AddAndRemoveUser(t *testing.T) { + db := testutil.SetupTestDB(t) + repo := NewResidenceRepository(db) + + owner := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password") + sharedUser := testutil.CreateTestUser(t, db, "shared", "shared@test.com", "password") + + residence := testutil.CreateTestResidence(t, db, owner.ID, "Test House") + + // Initially no access + hasAccess, _ := repo.HasAccess(residence.ID, sharedUser.ID) + assert.False(t, hasAccess) + + // Add user + err := repo.AddUser(residence.ID, sharedUser.ID) + require.NoError(t, err) + + hasAccess, _ = repo.HasAccess(residence.ID, sharedUser.ID) + assert.True(t, hasAccess) + + // Remove user + err = repo.RemoveUser(residence.ID, sharedUser.ID) + require.NoError(t, err) + + hasAccess, _ = repo.HasAccess(residence.ID, sharedUser.ID) + assert.False(t, hasAccess) +} + +func TestResidenceRepository_GetResidenceUsers(t *testing.T) { + db := testutil.SetupTestDB(t) + repo := NewResidenceRepository(db) + + owner := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password") + user1 := testutil.CreateTestUser(t, db, "user1", "user1@test.com", "password") + user2 := testutil.CreateTestUser(t, db, "user2", "user2@test.com", "password") + + residence := testutil.CreateTestResidence(t, db, owner.ID, "Test House") + repo.AddUser(residence.ID, user1.ID) + repo.AddUser(residence.ID, user2.ID) + + users, err := repo.GetResidenceUsers(residence.ID) + require.NoError(t, err) + assert.Len(t, users, 3) // owner + 2 shared users +} + +func TestResidenceRepository_CountByOwner(t *testing.T) { + db := testutil.SetupTestDB(t) + repo := NewResidenceRepository(db) + + owner := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password") + + testutil.CreateTestResidence(t, db, owner.ID, "House 1") + testutil.CreateTestResidence(t, db, owner.ID, "House 2") + testutil.CreateTestResidence(t, db, owner.ID, "House 3") + + count, err := repo.CountByOwner(owner.ID) + require.NoError(t, err) + assert.Equal(t, int64(3), count) +} + +func TestResidenceRepository_CreateShareCode(t *testing.T) { + db := testutil.SetupTestDB(t) + repo := NewResidenceRepository(db) + + owner := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password") + residence := testutil.CreateTestResidence(t, db, owner.ID, "Test House") + + shareCode, err := repo.CreateShareCode(residence.ID, owner.ID, 24*time.Hour) + require.NoError(t, err) + assert.NotEmpty(t, shareCode.Code) + assert.Len(t, shareCode.Code, 6) + assert.True(t, shareCode.IsActive) + assert.NotNil(t, shareCode.ExpiresAt) +} + +func TestResidenceRepository_FindShareCodeByCode(t *testing.T) { + db := testutil.SetupTestDB(t) + repo := NewResidenceRepository(db) + + owner := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password") + residence := testutil.CreateTestResidence(t, db, owner.ID, "Test House") + + created, err := repo.CreateShareCode(residence.ID, owner.ID, 24*time.Hour) + require.NoError(t, err) + + found, err := repo.FindShareCodeByCode(created.Code) + require.NoError(t, err) + assert.Equal(t, created.ID, found.ID) + assert.Equal(t, residence.ID, found.ResidenceID) +} + +func TestResidenceRepository_FindShareCodeByCode_Expired(t *testing.T) { + db := testutil.SetupTestDB(t) + repo := NewResidenceRepository(db) + + owner := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password") + residence := testutil.CreateTestResidence(t, db, owner.ID, "Test House") + + // Create expired code + created, err := repo.CreateShareCode(residence.ID, owner.ID, -1*time.Hour) // Already expired + require.NoError(t, err) + + _, err = repo.FindShareCodeByCode(created.Code) + assert.Error(t, err) // Should fail for expired code +} + +func TestResidenceRepository_GetAllResidenceTypes(t *testing.T) { + db := testutil.SetupTestDB(t) + repo := NewResidenceRepository(db) + + // Create residence types + types := []models.ResidenceType{ + {Name: "House"}, + {Name: "Apartment"}, + {Name: "Condo"}, + } + for _, rt := range types { + db.Create(&rt) + } + + result, err := repo.GetAllResidenceTypes() + require.NoError(t, err) + assert.Len(t, result, 3) +} diff --git a/internal/repositories/task_repo.go b/internal/repositories/task_repo.go index c620449..79b1c79 100644 --- a/internal/repositories/task_repo.go +++ b/internal/repositories/task_repo.go @@ -251,6 +251,132 @@ func (r *TaskRepository) GetKanbanData(residenceID uint, daysThreshold int) (*mo }, nil } +// GetKanbanDataForMultipleResidences retrieves tasks from multiple residences organized for kanban display +func (r *TaskRepository) GetKanbanDataForMultipleResidences(residenceIDs []uint, daysThreshold int) (*models.KanbanBoard, error) { + var tasks []models.Task + err := r.db.Preload("CreatedBy"). + Preload("AssignedTo"). + Preload("Category"). + Preload("Priority"). + Preload("Status"). + Preload("Frequency"). + Preload("Completions"). + Preload("Completions.CompletedBy"). + Preload("Residence"). + Where("residence_id IN ? AND is_archived = ?", residenceIDs, false). + Order("due_date ASC NULLS LAST, priority_id DESC, created_at DESC"). + Find(&tasks).Error + if err != nil { + return nil, err + } + + // Organize into columns + now := time.Now().UTC() + threshold := now.AddDate(0, 0, daysThreshold) + + overdue := make([]models.Task, 0) + dueSoon := make([]models.Task, 0) + upcoming := make([]models.Task, 0) + inProgress := make([]models.Task, 0) + completed := make([]models.Task, 0) + cancelled := make([]models.Task, 0) + + for _, task := range tasks { + if task.IsCancelled { + cancelled = append(cancelled, task) + continue + } + + // Check if completed (has completions) + if len(task.Completions) > 0 { + completed = append(completed, task) + continue + } + + // Check status for in-progress + if task.Status != nil && task.Status.Name == "In Progress" { + inProgress = append(inProgress, task) + continue + } + + // Check due date + if task.DueDate != nil { + if task.DueDate.Before(now) { + overdue = append(overdue, task) + } else if task.DueDate.Before(threshold) { + dueSoon = append(dueSoon, task) + } else { + upcoming = append(upcoming, task) + } + } else { + upcoming = append(upcoming, task) + } + } + + columns := []models.KanbanColumn{ + { + Name: "overdue_tasks", + DisplayName: "Overdue", + ButtonTypes: []string{"edit", "cancel", "mark_in_progress"}, + Icons: map[string]string{"ios": "exclamationmark.triangle", "android": "Warning"}, + Color: "#FF3B30", + Tasks: overdue, + Count: len(overdue), + }, + { + Name: "due_soon_tasks", + DisplayName: "Due Soon", + ButtonTypes: []string{"edit", "complete", "mark_in_progress"}, + Icons: map[string]string{"ios": "clock", "android": "Schedule"}, + Color: "#FF9500", + Tasks: dueSoon, + Count: len(dueSoon), + }, + { + Name: "upcoming_tasks", + DisplayName: "Upcoming", + ButtonTypes: []string{"edit", "cancel"}, + Icons: map[string]string{"ios": "calendar", "android": "Event"}, + Color: "#007AFF", + Tasks: upcoming, + Count: len(upcoming), + }, + { + Name: "in_progress_tasks", + DisplayName: "In Progress", + ButtonTypes: []string{"edit", "complete"}, + Icons: map[string]string{"ios": "hammer", "android": "Build"}, + Color: "#5856D6", + Tasks: inProgress, + Count: len(inProgress), + }, + { + Name: "completed_tasks", + DisplayName: "Completed", + ButtonTypes: []string{"view"}, + Icons: map[string]string{"ios": "checkmark.circle", "android": "CheckCircle"}, + Color: "#34C759", + Tasks: completed, + Count: len(completed), + }, + { + Name: "cancelled_tasks", + DisplayName: "Cancelled", + ButtonTypes: []string{"uncancel", "delete"}, + Icons: map[string]string{"ios": "xmark.circle", "android": "Cancel"}, + Color: "#8E8E93", + Tasks: cancelled, + Count: len(cancelled), + }, + } + + return &models.KanbanBoard{ + Columns: columns, + DaysThreshold: daysThreshold, + ResidenceID: "all", + }, nil +} + // === Lookup Operations === // GetAllCategories returns all task categories @@ -345,3 +471,79 @@ func (r *TaskRepository) FindCompletionsByUser(userID uint, residenceIDs []uint) func (r *TaskRepository) DeleteCompletion(id uint) error { return r.db.Delete(&models.TaskCompletion{}, id).Error } + +// TaskStatistics represents aggregated task statistics +type TaskStatistics struct { + TotalTasks int + TotalPending int + TotalOverdue int + TasksDueNextWeek int + TasksDueNextMonth int +} + +// GetTaskStatistics returns aggregated task statistics for multiple residences +func (r *TaskRepository) GetTaskStatistics(residenceIDs []uint) (*TaskStatistics, error) { + if len(residenceIDs) == 0 { + return &TaskStatistics{}, nil + } + + now := time.Now().UTC() + nextWeek := now.AddDate(0, 0, 7) + nextMonth := now.AddDate(0, 1, 0) + + var totalTasks, totalOverdue, totalPending, tasksDueNextWeek, tasksDueNextMonth int64 + + // Count total active tasks (not cancelled, not archived) + err := r.db.Model(&models.Task{}). + Where("residence_id IN ? AND is_cancelled = ? AND is_archived = ?", residenceIDs, false, false). + Count(&totalTasks).Error + if err != nil { + return nil, err + } + + // Count overdue tasks (due date < now, no completions) + err = r.db.Model(&models.Task{}). + Where("residence_id IN ? AND is_cancelled = ? AND is_archived = ? AND due_date < ?", residenceIDs, false, false, now). + Where("id NOT IN (?)", r.db.Table("task_taskcompletion").Select("task_id")). + Count(&totalOverdue).Error + if err != nil { + return nil, err + } + + // Count pending tasks (not completed, not cancelled, not archived) + err = r.db.Model(&models.Task{}). + Where("residence_id IN ? AND is_cancelled = ? AND is_archived = ?", residenceIDs, false, false). + Where("id NOT IN (?)", r.db.Table("task_taskcompletion").Select("task_id")). + Count(&totalPending).Error + if err != nil { + return nil, err + } + + // Count tasks due next week (due date between now and 7 days, not completed) + err = r.db.Model(&models.Task{}). + Where("residence_id IN ? AND is_cancelled = ? AND is_archived = ?", residenceIDs, false, false). + Where("due_date >= ? AND due_date < ?", now, nextWeek). + Where("id NOT IN (?)", r.db.Table("task_taskcompletion").Select("task_id")). + Count(&tasksDueNextWeek).Error + if err != nil { + return nil, err + } + + // Count tasks due next month (due date between now and 30 days, not completed) + err = r.db.Model(&models.Task{}). + Where("residence_id IN ? AND is_cancelled = ? AND is_archived = ?", residenceIDs, false, false). + Where("due_date >= ? AND due_date < ?", now, nextMonth). + Where("id NOT IN (?)", r.db.Table("task_taskcompletion").Select("task_id")). + Count(&tasksDueNextMonth).Error + if err != nil { + return nil, err + } + + return &TaskStatistics{ + TotalTasks: int(totalTasks), + TotalPending: int(totalPending), + TotalOverdue: int(totalOverdue), + TasksDueNextWeek: int(tasksDueNextWeek), + TasksDueNextMonth: int(tasksDueNextMonth), + }, nil +} diff --git a/internal/repositories/task_repo_test.go b/internal/repositories/task_repo_test.go new file mode 100644 index 0000000..75b6fcd --- /dev/null +++ b/internal/repositories/task_repo_test.go @@ -0,0 +1,315 @@ +package repositories + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/treytartt/mycrib-api/internal/models" + "github.com/treytartt/mycrib-api/internal/testutil" +) + +func TestTaskRepository_Create(t *testing.T) { + db := testutil.SetupTestDB(t) + repo := NewTaskRepository(db) + + user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password") + residence := testutil.CreateTestResidence(t, db, user.ID, "Test House") + + task := &models.Task{ + ResidenceID: residence.ID, + CreatedByID: user.ID, + Title: "Fix leaky faucet", + Description: "Kitchen faucet is dripping", + } + + err := repo.Create(task) + require.NoError(t, err) + assert.NotZero(t, task.ID) +} + +func TestTaskRepository_FindByID(t *testing.T) { + db := testutil.SetupTestDB(t) + repo := NewTaskRepository(db) + + user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password") + residence := testutil.CreateTestResidence(t, db, user.ID, "Test House") + task := testutil.CreateTestTask(t, db, residence.ID, user.ID, "Test Task") + + found, err := repo.FindByID(task.ID) + require.NoError(t, err) + assert.Equal(t, task.ID, found.ID) + assert.Equal(t, "Test Task", found.Title) +} + +func TestTaskRepository_FindByID_NotFound(t *testing.T) { + db := testutil.SetupTestDB(t) + repo := NewTaskRepository(db) + + _, err := repo.FindByID(9999) + assert.Error(t, err) +} + +func TestTaskRepository_FindByResidence(t *testing.T) { + db := testutil.SetupTestDB(t) + repo := NewTaskRepository(db) + + user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password") + residence1 := testutil.CreateTestResidence(t, db, user.ID, "House 1") + residence2 := testutil.CreateTestResidence(t, db, user.ID, "House 2") + + testutil.CreateTestTask(t, db, residence1.ID, user.ID, "Task 1") + testutil.CreateTestTask(t, db, residence1.ID, user.ID, "Task 2") + testutil.CreateTestTask(t, db, residence2.ID, user.ID, "Task 3") + + tasks, err := repo.FindByResidence(residence1.ID) + require.NoError(t, err) + assert.Len(t, tasks, 2) +} + +func TestTaskRepository_Update(t *testing.T) { + db := testutil.SetupTestDB(t) + repo := NewTaskRepository(db) + + user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password") + residence := testutil.CreateTestResidence(t, db, user.ID, "Test House") + task := testutil.CreateTestTask(t, db, residence.ID, user.ID, "Original Title") + + task.Title = "Updated Title" + task.Description = "Updated description" + err := repo.Update(task) + require.NoError(t, err) + + found, err := repo.FindByID(task.ID) + require.NoError(t, err) + assert.Equal(t, "Updated Title", found.Title) + assert.Equal(t, "Updated description", found.Description) +} + +func TestTaskRepository_Delete(t *testing.T) { + db := testutil.SetupTestDB(t) + repo := NewTaskRepository(db) + + user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password") + residence := testutil.CreateTestResidence(t, db, user.ID, "Test House") + task := testutil.CreateTestTask(t, db, residence.ID, user.ID, "Test Task") + + err := repo.Delete(task.ID) + require.NoError(t, err) + + _, err = repo.FindByID(task.ID) + assert.Error(t, err) // Should not be found +} + +func TestTaskRepository_Cancel(t *testing.T) { + db := testutil.SetupTestDB(t) + repo := NewTaskRepository(db) + + user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password") + residence := testutil.CreateTestResidence(t, db, user.ID, "Test House") + task := testutil.CreateTestTask(t, db, residence.ID, user.ID, "Test Task") + + assert.False(t, task.IsCancelled) + + err := repo.Cancel(task.ID) + require.NoError(t, err) + + found, err := repo.FindByID(task.ID) + require.NoError(t, err) + assert.True(t, found.IsCancelled) +} + +func TestTaskRepository_Uncancel(t *testing.T) { + db := testutil.SetupTestDB(t) + repo := NewTaskRepository(db) + + user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password") + residence := testutil.CreateTestResidence(t, db, user.ID, "Test House") + task := testutil.CreateTestTask(t, db, residence.ID, user.ID, "Test Task") + + repo.Cancel(task.ID) + err := repo.Uncancel(task.ID) + require.NoError(t, err) + + found, err := repo.FindByID(task.ID) + require.NoError(t, err) + assert.False(t, found.IsCancelled) +} + +func TestTaskRepository_Archive(t *testing.T) { + db := testutil.SetupTestDB(t) + repo := NewTaskRepository(db) + + user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password") + residence := testutil.CreateTestResidence(t, db, user.ID, "Test House") + task := testutil.CreateTestTask(t, db, residence.ID, user.ID, "Test Task") + + err := repo.Archive(task.ID) + require.NoError(t, err) + + found, err := repo.FindByID(task.ID) + require.NoError(t, err) + assert.True(t, found.IsArchived) +} + +func TestTaskRepository_Unarchive(t *testing.T) { + db := testutil.SetupTestDB(t) + repo := NewTaskRepository(db) + + user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password") + residence := testutil.CreateTestResidence(t, db, user.ID, "Test House") + task := testutil.CreateTestTask(t, db, residence.ID, user.ID, "Test Task") + + repo.Archive(task.ID) + err := repo.Unarchive(task.ID) + require.NoError(t, err) + + found, err := repo.FindByID(task.ID) + require.NoError(t, err) + assert.False(t, found.IsArchived) +} + +func TestTaskRepository_CreateCompletion(t *testing.T) { + db := testutil.SetupTestDB(t) + repo := NewTaskRepository(db) + + user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password") + residence := testutil.CreateTestResidence(t, db, user.ID, "Test House") + task := testutil.CreateTestTask(t, db, residence.ID, user.ID, "Test Task") + + completion := &models.TaskCompletion{ + TaskID: task.ID, + CompletedByID: user.ID, + CompletedAt: time.Now().UTC(), + Notes: "Completed successfully", + } + + err := repo.CreateCompletion(completion) + require.NoError(t, err) + assert.NotZero(t, completion.ID) +} + +func TestTaskRepository_FindCompletionByID(t *testing.T) { + db := testutil.SetupTestDB(t) + repo := NewTaskRepository(db) + + user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password") + residence := testutil.CreateTestResidence(t, db, user.ID, "Test House") + task := testutil.CreateTestTask(t, db, residence.ID, user.ID, "Test Task") + + completion := &models.TaskCompletion{ + TaskID: task.ID, + CompletedByID: user.ID, + CompletedAt: time.Now().UTC(), + Notes: "Test notes", + } + db.Create(completion) + + found, err := repo.FindCompletionByID(completion.ID) + require.NoError(t, err) + assert.Equal(t, completion.ID, found.ID) + assert.Equal(t, "Test notes", found.Notes) +} + +func TestTaskRepository_FindCompletionsByTask(t *testing.T) { + db := testutil.SetupTestDB(t) + repo := NewTaskRepository(db) + + user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password") + residence := testutil.CreateTestResidence(t, db, user.ID, "Test House") + task := testutil.CreateTestTask(t, db, residence.ID, user.ID, "Test Task") + + // Create multiple completions + for i := 0; i < 3; i++ { + db.Create(&models.TaskCompletion{ + TaskID: task.ID, + CompletedByID: user.ID, + CompletedAt: time.Now().UTC(), + }) + } + + completions, err := repo.FindCompletionsByTask(task.ID) + require.NoError(t, err) + assert.Len(t, completions, 3) +} + +func TestTaskRepository_DeleteCompletion(t *testing.T) { + db := testutil.SetupTestDB(t) + repo := NewTaskRepository(db) + + user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password") + residence := testutil.CreateTestResidence(t, db, user.ID, "Test House") + task := testutil.CreateTestTask(t, db, residence.ID, user.ID, "Test Task") + + completion := &models.TaskCompletion{ + TaskID: task.ID, + CompletedByID: user.ID, + CompletedAt: time.Now().UTC(), + } + db.Create(completion) + + err := repo.DeleteCompletion(completion.ID) + require.NoError(t, err) + + _, err = repo.FindCompletionByID(completion.ID) + assert.Error(t, err) +} + +func TestTaskRepository_GetAllCategories(t *testing.T) { + db := testutil.SetupTestDB(t) + repo := NewTaskRepository(db) + testutil.SeedLookupData(t, db) + + categories, err := repo.GetAllCategories() + require.NoError(t, err) + assert.Greater(t, len(categories), 0) +} + +func TestTaskRepository_GetAllPriorities(t *testing.T) { + db := testutil.SetupTestDB(t) + repo := NewTaskRepository(db) + testutil.SeedLookupData(t, db) + + priorities, err := repo.GetAllPriorities() + require.NoError(t, err) + assert.Greater(t, len(priorities), 0) +} + +func TestTaskRepository_GetAllStatuses(t *testing.T) { + db := testutil.SetupTestDB(t) + repo := NewTaskRepository(db) + testutil.SeedLookupData(t, db) + + statuses, err := repo.GetAllStatuses() + require.NoError(t, err) + assert.Greater(t, len(statuses), 0) +} + +func TestTaskRepository_GetAllFrequencies(t *testing.T) { + db := testutil.SetupTestDB(t) + repo := NewTaskRepository(db) + testutil.SeedLookupData(t, db) + + frequencies, err := repo.GetAllFrequencies() + require.NoError(t, err) + assert.Greater(t, len(frequencies), 0) +} + +func TestTaskRepository_CountByResidence(t *testing.T) { + db := testutil.SetupTestDB(t) + repo := NewTaskRepository(db) + + user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password") + residence := testutil.CreateTestResidence(t, db, user.ID, "Test House") + + testutil.CreateTestTask(t, db, residence.ID, user.ID, "Task 1") + testutil.CreateTestTask(t, db, residence.ID, user.ID, "Task 2") + testutil.CreateTestTask(t, db, residence.ID, user.ID, "Task 3") + + count, err := repo.CountByResidence(residence.ID) + require.NoError(t, err) + assert.Equal(t, int64(3), count) +} + diff --git a/internal/repositories/user_repo.go b/internal/repositories/user_repo.go index ec11800..fcc4b0c 100644 --- a/internal/repositories/user_repo.go +++ b/internal/repositories/user_repo.go @@ -80,10 +80,10 @@ func (r *UserRepository) FindByEmail(email string) (*models.User, error) { return &user, nil } -// FindByUsernameOrEmail finds a user by username or email +// FindByUsernameOrEmail finds a user by username or email with profile preloaded func (r *UserRepository) FindByUsernameOrEmail(identifier string) (*models.User, error) { var user models.User - if err := r.db.Where("LOWER(username) = LOWER(?) OR LOWER(email) = LOWER(?)", identifier, identifier).First(&user).Error; err != nil { + if err := r.db.Preload("Profile").Where("LOWER(username) = LOWER(?) OR LOWER(email) = LOWER(?)", identifier, identifier).First(&user).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, ErrUserNotFound } diff --git a/internal/repositories/user_repo_test.go b/internal/repositories/user_repo_test.go new file mode 100644 index 0000000..4aff5b2 --- /dev/null +++ b/internal/repositories/user_repo_test.go @@ -0,0 +1,189 @@ +package repositories + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/treytartt/mycrib-api/internal/models" + "github.com/treytartt/mycrib-api/internal/testutil" +) + +func TestUserRepository_Create(t *testing.T) { + db := testutil.SetupTestDB(t) + repo := NewUserRepository(db) + + user := &models.User{ + Username: "testuser", + Email: "test@example.com", + IsActive: true, + } + user.SetPassword("password123") + + err := repo.Create(user) + require.NoError(t, err) + assert.NotZero(t, user.ID) +} + +func TestUserRepository_FindByID(t *testing.T) { + db := testutil.SetupTestDB(t) + repo := NewUserRepository(db) + + // Create user + user := testutil.CreateTestUser(t, db, "testuser", "test@example.com", "password123") + + // Find by ID + found, err := repo.FindByID(user.ID) + require.NoError(t, err) + assert.Equal(t, user.ID, found.ID) + assert.Equal(t, "testuser", found.Username) + assert.Equal(t, "test@example.com", found.Email) +} + +func TestUserRepository_FindByID_NotFound(t *testing.T) { + db := testutil.SetupTestDB(t) + repo := NewUserRepository(db) + + _, err := repo.FindByID(9999) + assert.Error(t, err) +} + +func TestUserRepository_FindByUsername(t *testing.T) { + db := testutil.SetupTestDB(t) + repo := NewUserRepository(db) + + // Create user + user := testutil.CreateTestUser(t, db, "testuser", "test@example.com", "password123") + + // Find by username + found, err := repo.FindByUsername("testuser") + require.NoError(t, err) + assert.Equal(t, user.ID, found.ID) +} + +func TestUserRepository_FindByEmail(t *testing.T) { + db := testutil.SetupTestDB(t) + repo := NewUserRepository(db) + + // Create user + user := testutil.CreateTestUser(t, db, "testuser", "test@example.com", "password123") + + // Find by email + found, err := repo.FindByEmail("test@example.com") + require.NoError(t, err) + assert.Equal(t, user.ID, found.ID) +} + +func TestUserRepository_FindByUsernameOrEmail(t *testing.T) { + db := testutil.SetupTestDB(t) + repo := NewUserRepository(db) + + // Create user + user := testutil.CreateTestUser(t, db, "testuser", "test@example.com", "password123") + + tests := []struct { + name string + input string + expected uint + }{ + {"find by username", "testuser", user.ID}, + {"find by email", "test@example.com", user.ID}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + found, err := repo.FindByUsernameOrEmail(tt.input) + require.NoError(t, err) + assert.Equal(t, tt.expected, found.ID) + }) + } +} + +func TestUserRepository_Update(t *testing.T) { + db := testutil.SetupTestDB(t) + repo := NewUserRepository(db) + + // Create user + user := testutil.CreateTestUser(t, db, "testuser", "test@example.com", "password123") + + // Update user + user.FirstName = "John" + user.LastName = "Doe" + err := repo.Update(user) + require.NoError(t, err) + + // Verify update + found, err := repo.FindByID(user.ID) + require.NoError(t, err) + assert.Equal(t, "John", found.FirstName) + assert.Equal(t, "Doe", found.LastName) +} + +func TestUserRepository_ExistsByUsername(t *testing.T) { + db := testutil.SetupTestDB(t) + repo := NewUserRepository(db) + + // Create user + testutil.CreateTestUser(t, db, "existinguser", "existing@example.com", "password123") + + tests := []struct { + name string + username string + expected bool + }{ + {"existing user", "existinguser", true}, + {"non-existing user", "nonexistent", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + exists, err := repo.ExistsByUsername(tt.username) + require.NoError(t, err) + assert.Equal(t, tt.expected, exists) + }) + } +} + +func TestUserRepository_ExistsByEmail(t *testing.T) { + db := testutil.SetupTestDB(t) + repo := NewUserRepository(db) + + // Create user + testutil.CreateTestUser(t, db, "existinguser", "existing@example.com", "password123") + + tests := []struct { + name string + email string + expected bool + }{ + {"existing email", "existing@example.com", true}, + {"non-existing email", "nonexistent@example.com", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + exists, err := repo.ExistsByEmail(tt.email) + require.NoError(t, err) + assert.Equal(t, tt.expected, exists) + }) + } +} + +func TestUserRepository_GetOrCreateProfile(t *testing.T) { + db := testutil.SetupTestDB(t) + repo := NewUserRepository(db) + + // Create user + user := testutil.CreateTestUser(t, db, "testuser", "test@example.com", "password123") + + // First call should create + profile1, err := repo.GetOrCreateProfile(user.ID) + require.NoError(t, err) + assert.NotZero(t, profile1.ID) + + // Second call should return same profile + profile2, err := repo.GetOrCreateProfile(user.ID) + require.NoError(t, err) + assert.Equal(t, profile1.ID, profile2.ID) +} diff --git a/internal/router/router.go b/internal/router/router.go index cd9b06d..91c55c4 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -8,6 +8,7 @@ import ( "github.com/gin-gonic/gin" "gorm.io/gorm" + "github.com/treytartt/mycrib-api/internal/admin" "github.com/treytartt/mycrib-api/internal/config" "github.com/treytartt/mycrib-api/internal/handlers" "github.com/treytartt/mycrib-api/internal/middleware" @@ -21,11 +22,13 @@ const Version = "2.0.0" // Dependencies holds all dependencies needed by the router type Dependencies struct { - DB *gorm.DB - Cache *services.CacheService - Config *config.Config - EmailService *services.EmailService - PushClient interface{} // *push.GorushClient - optional + DB *gorm.DB + Cache *services.CacheService + Config *config.Config + EmailService *services.EmailService + PDFService *services.PDFService + PushClient interface{} // *push.GorushClient - optional + StorageService *services.StorageService } // SetupRouter creates and configures the Gin router @@ -49,6 +52,11 @@ func SetupRouter(deps *Dependencies) *gin.Engine { // Health check endpoint (no auth required) r.GET("/api/health/", healthCheck) + // Serve static files from uploads directory + if cfg.Storage.UploadDir != "" { + r.Static("/uploads", cfg.Storage.UploadDir) + } + // Initialize repositories userRepo := repositories.NewUserRepository(deps.DB) residenceRepo := repositories.NewResidenceRepository(deps.DB) @@ -70,6 +78,7 @@ func SetupRouter(deps *Dependencies) *gin.Engine { authService := services.NewAuthService(userRepo, cfg) userService := services.NewUserService(userRepo) residenceService := services.NewResidenceService(residenceRepo, userRepo, cfg) + residenceService.SetTaskRepository(taskRepo) // Wire up task repo for statistics taskService := services.NewTaskService(taskRepo, residenceRepo) contractorService := services.NewContractorService(contractorRepo, residenceRepo) documentService := services.NewDocumentService(documentRepo, residenceRepo) @@ -86,14 +95,31 @@ func SetupRouter(deps *Dependencies) *gin.Engine { // Initialize handlers authHandler := handlers.NewAuthHandler(authService, deps.EmailService, deps.Cache) userHandler := handlers.NewUserHandler(userService) - residenceHandler := handlers.NewResidenceHandler(residenceService) - taskHandler := handlers.NewTaskHandler(taskService) + residenceHandler := handlers.NewResidenceHandler(residenceService, deps.PDFService, deps.EmailService) + taskHandler := handlers.NewTaskHandler(taskService, deps.StorageService) contractorHandler := handlers.NewContractorHandler(contractorService) - documentHandler := handlers.NewDocumentHandler(documentService) + documentHandler := handlers.NewDocumentHandler(documentService, deps.StorageService) notificationHandler := handlers.NewNotificationHandler(notificationService) subscriptionHandler := handlers.NewSubscriptionHandler(subscriptionService) staticDataHandler := handlers.NewStaticDataHandler(residenceService, taskService, contractorService) + // Initialize upload handler (if storage service is available) + var uploadHandler *handlers.UploadHandler + if deps.StorageService != nil { + uploadHandler = handlers.NewUploadHandler(deps.StorageService) + } + + // Set up admin routes (separate auth system) + adminDeps := &admin.Dependencies{ + EmailService: deps.EmailService, + } + if deps.PushClient != nil { + if gc, ok := deps.PushClient.(*push.GorushClient); ok { + adminDeps.PushClient = gc + } + } + admin.SetupRoutes(r, deps.DB, cfg, adminDeps) + // API group api := r.Group("/api") { @@ -115,6 +141,11 @@ func SetupRouter(deps *Dependencies) *gin.Engine { setupNotificationRoutes(protected, notificationHandler) setupSubscriptionRoutes(protected, subscriptionHandler) setupUserRoutes(protected, userHandler) + + // Upload routes (only if storage service is configured) + if uploadHandler != nil { + setupUploadRoutes(protected, uploadHandler) + } } } @@ -225,6 +256,7 @@ func setupTaskRoutes(api *gin.RouterGroup, taskHandler *handlers.TaskHandler) { tasks.POST("/:id/uncancel/", taskHandler.UncancelTask) tasks.POST("/:id/archive/", taskHandler.ArchiveTask) tasks.POST("/:id/unarchive/", taskHandler.UnarchiveTask) + tasks.GET("/:id/completions/", taskHandler.GetTaskCompletions) } // Task Completions @@ -311,3 +343,14 @@ func setupUserRoutes(api *gin.RouterGroup, userHandler *handlers.UserHandler) { users.GET("/profiles/", userHandler.ListProfiles) } } + +// setupUploadRoutes configures file upload routes +func setupUploadRoutes(api *gin.RouterGroup, uploadHandler *handlers.UploadHandler) { + uploads := api.Group("/uploads") + { + uploads.POST("/image/", uploadHandler.UploadImage) + uploads.POST("/document/", uploadHandler.UploadDocument) + uploads.POST("/completion/", uploadHandler.UploadCompletion) + uploads.DELETE("/", uploadHandler.DeleteFile) + } +} diff --git a/internal/services/contractor_service.go b/internal/services/contractor_service.go index 2cadfa7..14cdf41 100644 --- a/internal/services/contractor_service.go +++ b/internal/services/contractor_service.go @@ -55,7 +55,7 @@ func (s *ContractorService) GetContractor(contractorID, userID uint) (*responses } // ListContractors lists all contractors accessible to a user -func (s *ContractorService) ListContractors(userID uint) (*responses.ContractorListResponse, error) { +func (s *ContractorService) ListContractors(userID uint) ([]responses.ContractorResponse, error) { residences, err := s.residenceRepo.FindByUser(userID) if err != nil { return nil, err @@ -67,7 +67,7 @@ func (s *ContractorService) ListContractors(userID uint) (*responses.ContractorL } if len(residenceIDs) == 0 { - return &responses.ContractorListResponse{Count: 0, Results: []responses.ContractorResponse{}}, nil + return []responses.ContractorResponse{}, nil } contractors, err := s.contractorRepo.FindByUser(residenceIDs) @@ -75,8 +75,7 @@ func (s *ContractorService) ListContractors(userID uint) (*responses.ContractorL return nil, err } - resp := responses.NewContractorListResponse(contractors) - return &resp, nil + return responses.NewContractorListResponse(contractors), nil } // CreateContractor creates a new contractor @@ -234,8 +233,8 @@ func (s *ContractorService) DeleteContractor(contractorID, userID uint) error { return s.contractorRepo.Delete(contractorID) } -// ToggleFavorite toggles the favorite status of a contractor -func (s *ContractorService) ToggleFavorite(contractorID, userID uint) (*responses.ToggleFavoriteResponse, error) { +// ToggleFavorite toggles the favorite status of a contractor and returns the updated contractor +func (s *ContractorService) ToggleFavorite(contractorID, userID uint) (*responses.ContractorResponse, error) { contractor, err := s.contractorRepo.FindByID(contractorID) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { @@ -253,24 +252,23 @@ func (s *ContractorService) ToggleFavorite(contractorID, userID uint) (*response return nil, ErrContractorAccessDenied } - newStatus, err := s.contractorRepo.ToggleFavorite(contractorID) + _, err = s.contractorRepo.ToggleFavorite(contractorID) if err != nil { return nil, err } - message := "Contractor removed from favorites" - if newStatus { - message = "Contractor added to favorites" + // Re-fetch the contractor to get the updated state with all relations + contractor, err = s.contractorRepo.FindByID(contractorID) + if err != nil { + return nil, err } - return &responses.ToggleFavoriteResponse{ - Message: message, - IsFavorite: newStatus, - }, nil + resp := responses.NewContractorResponse(contractor) + return &resp, nil } // GetContractorTasks gets all tasks for a contractor -func (s *ContractorService) GetContractorTasks(contractorID, userID uint) (*responses.TaskListResponse, error) { +func (s *ContractorService) GetContractorTasks(contractorID, userID uint) ([]responses.TaskResponse, error) { contractor, err := s.contractorRepo.FindByID(contractorID) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { @@ -293,8 +291,7 @@ func (s *ContractorService) GetContractorTasks(contractorID, userID uint) (*resp return nil, err } - resp := responses.NewTaskListResponse(tasks) - return &resp, nil + return responses.NewTaskListResponse(tasks), nil } // GetSpecialties returns all contractor specialties diff --git a/internal/services/document_service.go b/internal/services/document_service.go index f0e8658..c55fd4d 100644 --- a/internal/services/document_service.go +++ b/internal/services/document_service.go @@ -55,7 +55,7 @@ func (s *DocumentService) GetDocument(documentID, userID uint) (*responses.Docum } // ListDocuments lists all documents accessible to a user -func (s *DocumentService) ListDocuments(userID uint) (*responses.DocumentListResponse, error) { +func (s *DocumentService) ListDocuments(userID uint) ([]responses.DocumentResponse, error) { residences, err := s.residenceRepo.FindByUser(userID) if err != nil { return nil, err @@ -67,7 +67,7 @@ func (s *DocumentService) ListDocuments(userID uint) (*responses.DocumentListRes } if len(residenceIDs) == 0 { - return &responses.DocumentListResponse{Count: 0, Results: []responses.DocumentResponse{}}, nil + return []responses.DocumentResponse{}, nil } documents, err := s.documentRepo.FindByUser(residenceIDs) @@ -75,12 +75,11 @@ func (s *DocumentService) ListDocuments(userID uint) (*responses.DocumentListRes return nil, err } - resp := responses.NewDocumentListResponse(documents) - return &resp, nil + return responses.NewDocumentListResponse(documents), nil } // ListWarranties lists all warranty documents -func (s *DocumentService) ListWarranties(userID uint) (*responses.DocumentListResponse, error) { +func (s *DocumentService) ListWarranties(userID uint) ([]responses.DocumentResponse, error) { residences, err := s.residenceRepo.FindByUser(userID) if err != nil { return nil, err @@ -92,7 +91,7 @@ func (s *DocumentService) ListWarranties(userID uint) (*responses.DocumentListRe } if len(residenceIDs) == 0 { - return &responses.DocumentListResponse{Count: 0, Results: []responses.DocumentResponse{}}, nil + return []responses.DocumentResponse{}, nil } documents, err := s.documentRepo.FindWarranties(residenceIDs) @@ -100,8 +99,7 @@ func (s *DocumentService) ListWarranties(userID uint) (*responses.DocumentListRe return nil, err } - resp := responses.NewDocumentListResponse(documents) - return &resp, nil + return responses.NewDocumentListResponse(documents), nil } // CreateDocument creates a new document diff --git a/internal/services/email_service.go b/internal/services/email_service.go index ffd6e0f..862f97f 100644 --- a/internal/services/email_service.go +++ b/internal/services/email_service.go @@ -4,6 +4,7 @@ import ( "bytes" "fmt" "html/template" + "io" "time" "github.com/rs/zerolog/log" @@ -46,6 +47,43 @@ func (s *EmailService) SendEmail(to, subject, htmlBody, textBody string) error { return nil } +// EmailAttachment represents an email attachment +type EmailAttachment struct { + Filename string + ContentType string + Data []byte +} + +// SendEmailWithAttachment sends an email with an attachment +func (s *EmailService) SendEmailWithAttachment(to, subject, htmlBody, textBody string, attachment *EmailAttachment) error { + m := gomail.NewMessage() + m.SetHeader("From", s.cfg.From) + m.SetHeader("To", to) + m.SetHeader("Subject", subject) + m.SetBody("text/plain", textBody) + m.AddAlternative("text/html", htmlBody) + + if attachment != nil { + m.Attach(attachment.Filename, + gomail.SetCopyFunc(func(w io.Writer) error { + _, err := w.Write(attachment.Data) + return err + }), + gomail.SetHeader(map[string][]string{ + "Content-Type": {attachment.ContentType}, + }), + ) + } + + if err := s.dialer.DialAndSend(m); err != nil { + log.Error().Err(err).Str("to", to).Str("subject", subject).Msg("Failed to send email with attachment") + return fmt.Errorf("failed to send email: %w", err) + } + + log.Info().Str("to", to).Str("subject", subject).Str("attachment", attachment.Filename).Msg("Email with attachment sent successfully") + return nil +} + // SendWelcomeEmail sends a welcome email with verification code func (s *EmailService) SendWelcomeEmail(to, firstName, code string) error { subject := "Welcome to MyCrib - Verify Your Email" @@ -342,6 +380,110 @@ The MyCrib Team return s.SendEmail(to, subject, htmlBody, textBody) } +// SendTasksReportEmail sends a tasks report email with PDF attachment +func (s *EmailService) SendTasksReportEmail(to, recipientName, residenceName string, totalTasks, completed, pending, overdue int, pdfData []byte) error { + subject := fmt.Sprintf("MyCrib - Tasks Report for %s", residenceName) + + name := recipientName + if name == "" { + name = "there" + } + + htmlBody := fmt.Sprintf(` + + +
+ + + + +%s
+Hi %s,
+Here's your tasks report for %s. The full report is attached as a PDF.
+|
+ %d
+ Total Tasks
+ |
+
+ %d
+ Completed
+ |
+
+ %d
+ Pending
+ |
+
+ %d
+ Overdue
+ |
+
Open the attached PDF for the complete list of tasks with details.
+Best regards,
The MyCrib Team