not tracked not covered covered

    package main

    import (
            "log"
            "os"

            "github.com/gofiber/fiber/v2"
            "github.com/joho/godotenv"

            "wucher/internal/app/api"
            "wucher/internal/config"
            "wucher/internal/repository/mysql"
            "wucher/internal/repository/redis"
            "wucher/internal/service"
            "wucher/internal/transport/http/handlers"

            _ "wucher/docs"
    )

    // @title Wucher API
    // @version 1.0
    // @description Wucher backend API (JSON:API).
    // @description
    // @description Auth Flow (Email/Password):
    // @description 1) POST /api/v1/auth/register
    // @description 2) Check email, open verify link (GET /api/v1/auth/verify-email)
    // @description 3) POST /api/v1/auth/login
    // @description - If TOTP enabled: response 202 with challenge_token
    // @description 4) POST /api/v1/auth/totp/verify (challenge_token + code) -> cookies
    // @description
    // @description Auth Flow (TOTP setup):
    // @description 1) POST /api/v1/auth/totp/setup -> secret + otpauth_url (enabled=false)
    // @description 2) POST /api/v1/auth/totp/confirm -> enabled=true
    // @description
    // @description Auth Flow (Microsoft Entra SSO):
    // @description 1) GET /api/v1/auth/microsoft/login -> redirect to Microsoft
    // @description 2) Microsoft redirects to /api/v1/auth/microsoft/callback
    // @description 3) Backend sets cookies then redirects to AUTH_SSO_SUCCESS_REDIRECT
    // @BasePath /
    func main() {
            _ = godotenv.Load()

            cfg := config.LoadMySQLConfigFromEnv()
            db, err := mysql.Connect(cfg)
            if err != nil {
                    log.Fatal(err)
            }

            if err := mysql.AutoMigrate(db); err != nil {
                    log.Fatal(err)
            }
            if err := api.SeedRoles(db); err != nil {
                    log.Fatal(err)
            }

            userRepo := mysql.NewUserRepository(db)
            userSvc := service.NewUserService(userRepo)

            roleRepo := mysql.NewRoleRepository(db)
            roleSvc := service.NewRoleService(roleRepo)

            authCfg := config.LoadAuthConfigFromEnv()
            redisCfg := config.LoadRedisConfigFromEnv()
            redisClient := redis.NewClient(redisCfg)
            tokenStore := redis.NewTokenStore(redisClient)
            smtpCfg := config.LoadSMTPConfigFromEnv()
            mailer := service.NewSMTPSender(smtpCfg)
            emailQueue := redis.NewEmailQueue(redisClient, authCfg.EmailQueueKey)

            msCfg := config.LoadMicrosoftSSOConfigFromEnv()
            var msSSO service.MicrosoftSSO
            if msCfg.TenantID != "" && msCfg.ClientID != "" && msCfg.ClientSecret != "" && msCfg.RedirectURL != "" {
                    msSSO = service.NewMicrosoftSSO(msCfg)
            }

            authRepo := mysql.NewAuthRepository(db)
            var protector service.SecretProtector
            if p, err := service.NewAESGCMProtectorFromBase64(authCfg.TOTPSecretKeyB64); err == nil {
                    protector = p
            }
            authSvc := service.NewAuthService(authRepo, roleRepo, tokenStore, emailQueue, mailer, msSSO, protector, authCfg)

            app := fiber.New()
            api.UseDefaultMiddlewares(app)
            api.RegisterRoutes(
                    app,
                    handlers.NewUserHandler(userSvc),
                    handlers.NewHealthHandler(),
                    handlers.NewAuthHandler(authSvc),
                    authSvc,
                    handlers.NewRoleHandler(roleSvc),
            )

            addr := ":8080"
            if v := os.Getenv("HTTP_PORT"); v != "" {
                    addr = ":" + v
            }

            srv := api.NewServer(app)
            log.Printf("API listening on %s", addr)
            if err := srv.Start(addr); err != nil {
                    log.Fatal(err)
            }
    }

    package main

    import (
            "context"
            "log"
            "os/signal"
            "syscall"

            "github.com/joho/godotenv"

            "wucher/internal/config"
            "wucher/internal/repository/redis"
            "wucher/internal/service"
    )

    func main() {
            _ = godotenv.Load()

            ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
            defer stop()

            authCfg := config.LoadAuthConfigFromEnv()
            redisCfg := config.LoadRedisConfigFromEnv()
            redisClient := redis.NewClient(redisCfg)
            queue := redis.NewEmailQueue(redisClient, authCfg.EmailQueueKey)

            smtpCfg := config.LoadSMTPConfigFromEnv()
            mailer := service.NewSMTPSender(smtpCfg)

            log.Println("worker started: email queue")
            for {
                    select {
                    case <-ctx.Done():
                            log.Println("worker stopped")
                            return
                    default:
                            job, err := queue.Dequeue(ctx)
                            if err != nil {
                                    log.Printf("queue error: %v", err)
                                    continue
                            }
                            if job == nil {
                                    continue
                            }
                            if err := mailer.Send(ctx, job.To, job.Subject, job.Body); err != nil {
                                    log.Printf("send email failed: %v", err)
                            }
                    }
            }
    }

    // Package docs Code generated by swaggo/swag. DO NOT EDIT
    package docs

    import "github.com/swaggo/swag"

    const docTemplate = `{
        "schemes": {{ marshal .Schemes }},
        "swagger": "2.0",
        "info": {
            "description": "{{escape .Description}}",
            "title": "{{.Title}}",
            "contact": {},
            "version": "{{.Version}}"
        },
        "host": "{{.Host}}",
        "basePath": "{{.BasePath}}",
        "paths": {
            "/api/v1/auth/login": {
                "post": {
                    "description": "If TOTP enabled, returns 202 + challenge_token. Then call /auth/totp/verify.",
                    "consumes": [
                        "application/json"
                    ],
                    "produces": [
                        "application/json"
                    ],
                    "tags": [
                        "Auth"
                    ],
                    "summary": "Login with email \u0026 password",
                    "parameters": [
                        {
                            "description": "JSON:API Login request",
                            "name": "request",
                            "in": "body",
                            "required": true,
                            "schema": {
                                "$ref": "#/definitions/dto.LoginRequest"
                            }
                        }
                    ],
                    "responses": {
                        "200": {
                            "description": "OK",
                            "schema": {
                                "$ref": "#/definitions/dto.UserResponse"
                            }
                        },
                        "202": {
                            "description": "Accepted",
                            "schema": {
                                "$ref": "#/definitions/dto.LoginTOTPRequiredResponse"
                            }
                        },
                        "401": {
                            "description": "Unauthorized",
                            "schema": {
                                "$ref": "#/definitions/dto.ErrorResponse"
                            }
                        },
                        "422": {
                            "description": "Unprocessable Entity",
                            "schema": {
                                "$ref": "#/definitions/dto.ErrorResponse"
                            }
                        }
                    }
                }
            },
            "/api/v1/auth/logout": {
                "post": {
                    "produces": [
                        "application/json"
                    ],
                    "tags": [
                        "Auth"
                    ],
                    "summary": "Logout (revoke refresh)",
                    "responses": {
                        "200": {
                            "description": "OK",
                            "schema": {
                                "$ref": "#/definitions/dto.VerifyEmailResponse"
                            }
                        }
                    }
                }
            },
            "/api/v1/auth/me": {
                "get": {
                    "produces": [
                        "application/json"
                    ],
                    "tags": [
                        "Auth"
                    ],
                    "summary": "Get current user",
                    "responses": {
                        "200": {
                            "description": "OK",
                            "schema": {
                                "$ref": "#/definitions/dto.UserResponse"
                            }
                        },
                        "401": {
                            "description": "Unauthorized",
                            "schema": {
                                "$ref": "#/definitions/dto.ErrorResponse"
                            }
                        }
                    }
                }
            },
            "/api/v1/auth/microsoft/callback": {
                "get": {
                    "description": "Sets auth cookies and redirects to AUTH_SSO_SUCCESS_REDIRECT if configured.",
                    "produces": [
                        "application/json"
                    ],
                    "tags": [
                        "Auth"
                    ],
                    "summary": "Microsoft Entra SSO callback",
                    "parameters": [
                        {
                            "type": "string",
                            "description": "Authorization code",
                            "name": "code",
                            "in": "query",
                            "required": true
                        },
                        {
                            "type": "string",
                            "description": "State",
                            "name": "state",
                            "in": "query",
                            "required": true
                        }
                    ],
                    "responses": {
                        "200": {
                            "description": "OK",
                            "schema": {
                                "$ref": "#/definitions/dto.MicrosoftCallbackResponse"
                            }
                        },
                        "400": {
                            "description": "Bad Request",
                            "schema": {
                                "$ref": "#/definitions/dto.ErrorResponse"
                            }
                        }
                    }
                }
            },
            "/api/v1/auth/microsoft/login": {
                "get": {
                    "description": "Returns the Microsoft authorization URL. Browser should redirect user to this URL.",
                    "produces": [
                        "application/json"
                    ],
                    "tags": [
                        "Auth"
                    ],
                    "summary": "Microsoft Entra SSO login",
                    "responses": {
                        "200": {
                            "description": "OK",
                            "schema": {
                                "$ref": "#/definitions/dto.MicrosoftLoginResponse"
                            }
                        },
                        "400": {
                            "description": "Bad Request",
                            "schema": {
                                "$ref": "#/definitions/dto.ErrorResponse"
                            }
                        }
                    }
                }
            },
            "/api/v1/auth/refresh": {
                "post": {
                    "produces": [
                        "application/json"
                    ],
                    "tags": [
                        "Auth"
                    ],
                    "summary": "Refresh access token",
                    "responses": {
                        "200": {
                            "description": "OK",
                            "schema": {
                                "$ref": "#/definitions/dto.TokenResponse"
                            }
                        },
                        "401": {
                            "description": "Unauthorized",
                            "schema": {
                                "$ref": "#/definitions/dto.ErrorResponse"
                            }
                        }
                    }
                }
            },
            "/api/v1/auth/register": {
                "post": {
                    "description": "Creates user (default role: admin) and sends verification link to email.\nNext: open verify link -\u003e then login.",
                    "consumes": [
                        "application/json"
                    ],
                    "produces": [
                        "application/json"
                    ],
                    "tags": [
                        "Auth"
                    ],
                    "summary": "Register user with email \u0026 password",
                    "parameters": [
                        {
                            "description": "JSON:API Register request",
                            "name": "request",
                            "in": "body",
                            "required": true,
                            "schema": {
                                "$ref": "#/definitions/dto.RegisterRequest"
                            }
                        }
                    ],
                    "responses": {
                        "201": {
                            "description": "Created",
                            "schema": {
                                "$ref": "#/definitions/dto.UserResponse"
                            }
                        },
                        "400": {
                            "description": "Bad Request",
                            "schema": {
                                "$ref": "#/definitions/dto.ErrorResponse"
                            }
                        },
                        "422": {
                            "description": "Unprocessable Entity",
                            "schema": {
                                "$ref": "#/definitions/dto.ErrorResponse"
                            }
                        }
                    }
                }
            },
            "/api/v1/auth/totp/confirm": {
                "post": {
                    "description": "Validates initial TOTP code and enables TOTP for future logins.",
                    "consumes": [
                        "application/json"
                    ],
                    "produces": [
                        "application/json"
                    ],
                    "tags": [
                        "Auth"
                    ],
                    "summary": "Confirm TOTP setup",
                    "parameters": [
                        {
                            "description": "JSON:API TOTP confirm request",
                            "name": "request",
                            "in": "body",
                            "required": true,
                            "schema": {
                                "$ref": "#/definitions/dto.TOTPConfirmRequest"
                            }
                        }
                    ],
                    "responses": {
                        "200": {
                            "description": "OK",
                            "schema": {
                                "$ref": "#/definitions/dto.VerifyEmailResponse"
                            }
                        },
                        "422": {
                            "description": "Unprocessable Entity",
                            "schema": {
                                "$ref": "#/definitions/dto.ErrorResponse"
                            }
                        }
                    }
                }
            },
            "/api/v1/auth/totp/disable": {
                "post": {
                    "consumes": [
                        "application/json"
                    ],
                    "produces": [
                        "application/json"
                    ],
                    "tags": [
                        "Auth"
                    ],
                    "summary": "Disable TOTP",
                    "parameters": [
                        {
                            "description": "JSON:API TOTP disable request",
                            "name": "request",
                            "in": "body",
                            "required": true,
                            "schema": {
                                "$ref": "#/definitions/dto.TOTPDisableRequest"
                            }
                        }
                    ],
                    "responses": {
                        "200": {
                            "description": "OK",
                            "schema": {
                                "$ref": "#/definitions/dto.VerifyEmailResponse"
                            }
                        },
                        "422": {
                            "description": "Unprocessable Entity",
                            "schema": {
                                "$ref": "#/definitions/dto.ErrorResponse"
                            }
                        }
                    }
                }
            },
            "/api/v1/auth/totp/setup": {
                "post": {
                    "description": "Generates secret and otpauth URL. TOTP is NOT enabled until confirmed.",
                    "consumes": [
                        "application/json"
                    ],
                    "produces": [
                        "application/json"
                    ],
                    "tags": [
                        "Auth"
                    ],
                    "summary": "Setup TOTP",
                    "parameters": [
                        {
                            "description": "JSON:API TOTP setup request",
                            "name": "request",
                            "in": "body",
                            "required": true,
                            "schema": {
                                "$ref": "#/definitions/dto.TOTPSetupRequest"
                            }
                        }
                    ],
                    "responses": {
                        "200": {
                            "description": "OK",
                            "schema": {
                                "$ref": "#/definitions/dto.TOTPSetupResponse"
                            }
                        },
                        "422": {
                            "description": "Unprocessable Entity",
                            "schema": {
                                "$ref": "#/definitions/dto.ErrorResponse"
                            }
                        }
                    }
                }
            },
            "/api/v1/auth/totp/verify": {
                "post": {
                    "description": "Completes login when TOTP is enabled and returns auth cookies.",
                    "consumes": [
                        "application/json"
                    ],
                    "produces": [
                        "application/json"
                    ],
                    "tags": [
                        "Auth"
                    ],
                    "summary": "Verify TOTP code",
                    "parameters": [
                        {
                            "description": "JSON:API TOTP verify request",
                            "name": "request",
                            "in": "body",
                            "required": true,
                            "schema": {
                                "$ref": "#/definitions/dto.TOTPVerifyRequest"
                            }
                        }
                    ],
                    "responses": {
                        "200": {
                            "description": "OK",
                            "schema": {
                                "$ref": "#/definitions/dto.TokenResponse"
                            }
                        },
                        "422": {
                            "description": "Unprocessable Entity",
                            "schema": {
                                "$ref": "#/definitions/dto.ErrorResponse"
                            }
                        }
                    }
                }
            },
            "/api/v1/auth/verify-email": {
                "get": {
                    "description": "Confirms email verification after register.",
                    "produces": [
                        "application/json"
                    ],
                    "tags": [
                        "Auth"
                    ],
                    "summary": "Verify email",
                    "parameters": [
                        {
                            "type": "string",
                            "description": "Verification token",
                            "name": "token",
                            "in": "query",
                            "required": true
                        }
                    ],
                    "responses": {
                        "200": {
                            "description": "OK",
                            "schema": {
                                "$ref": "#/definitions/dto.VerifyEmailResponse"
                            }
                        },
                        "400": {
                            "description": "Bad Request",
                            "schema": {
                                "$ref": "#/definitions/dto.ErrorResponse"
                            }
                        }
                    }
                }
            },
            "/api/v1/roles": {
                "get": {
                    "description": "JSON:API list with pagination, filtering and sorting. Sort values: name, -name, created_at, -created_at, updated_at, -updated_at.",
                    "produces": [
                        "application/json"
                    ],
                    "tags": [
                        "Roles"
                    ],
                    "summary": "List roles",
                    "parameters": [
                        {
                            "type": "string",
                            "description": "Filter by name (contains)",
                            "name": "filter[name]",
                            "in": "query"
                        },
                        {
                            "type": "integer",
                            "description": "Page number (default 1)",
                            "name": "page[number]",
                            "in": "query"
                        },
                        {
                            "type": "integer",
                            "description": "Page size (default 20, max 100)",
                            "name": "page[size]",
                            "in": "query"
                        },
                        {
                            "type": "string",
                            "description": "Sort order",
                            "name": "sort",
                            "in": "query"
                        }
                    ],
                    "responses": {
                        "200": {
                            "description": "OK",
                            "schema": {
                                "$ref": "#/definitions/dto.RoleListResponse"
                            }
                        }
                    }
                },
                "post": {
                    "description": "Create a new role. Name must be unique.",
                    "consumes": [
                        "application/json"
                    ],
                    "produces": [
                        "application/json"
                    ],
                    "tags": [
                        "Roles"
                    ],
                    "summary": "Create role",
                    "parameters": [
                        {
                            "description": "JSON:API Role create request",
                            "name": "request",
                            "in": "body",
                            "required": true,
                            "schema": {
                                "$ref": "#/definitions/dto.RoleCreateRequest"
                            }
                        }
                    ],
                    "responses": {
                        "201": {
                            "description": "Created",
                            "schema": {
                                "$ref": "#/definitions/dto.RoleResponse"
                            }
                        },
                        "400": {
                            "description": "Bad Request",
                            "schema": {
                                "$ref": "#/definitions/dto.ErrorResponse"
                            }
                        },
                        "422": {
                            "description": "Unprocessable Entity",
                            "schema": {
                                "$ref": "#/definitions/dto.ErrorResponse"
                            }
                        }
                    }
                }
            },
            "/api/v1/roles/datatable": {
                "get": {
                    "description": "DataTables-style params with JSON:API response. Use draw/start/length/search[value].",
                    "produces": [
                        "application/json"
                    ],
                    "tags": [
                        "Roles"
                    ],
                    "summary": "List roles (datatable)",
                    "parameters": [
                        {
                            "type": "integer",
                            "description": "Draw counter",
                            "name": "draw",
                            "in": "query"
                        },
                        {
                            "type": "integer",
                            "description": "Offset",
                            "name": "start",
                            "in": "query"
                        },
                        {
                            "type": "integer",
                            "description": "Page size",
                            "name": "length",
                            "in": "query"
                        },
                        {
                            "type": "string",
                            "description": "Search term",
                            "name": "search[value]",
                            "in": "query"
                        }
                    ],
                    "responses": {
                        "200": {
                            "description": "OK",
                            "schema": {
                                "$ref": "#/definitions/dto.RoleDataTableResponse"
                            }
                        }
                    }
                }
            },
            "/api/v1/roles/{id}": {
                "get": {
                    "produces": [
                        "application/json"
                    ],
                    "tags": [
                        "Roles"
                    ],
                    "summary": "Get role by ID",
                    "parameters": [
                        {
                            "type": "string",
                            "description": "Role ID (UUIDv7)",
                            "name": "id",
                            "in": "path",
                            "required": true
                        }
                    ],
                    "responses": {
                        "200": {
                            "description": "OK",
                            "schema": {
                                "$ref": "#/definitions/dto.RoleResponse"
                            }
                        },
                        "404": {
                            "description": "Not Found",
                            "schema": {
                                "$ref": "#/definitions/dto.ErrorResponse"
                            }
                        }
                    }
                },
                "put": {
                    "description": "Update role by ID. Name must be unique.",
                    "consumes": [
                        "application/json"
                    ],
                    "produces": [
                        "application/json"
                    ],
                    "tags": [
                        "Roles"
                    ],
                    "summary": "Update role",
                    "parameters": [
                        {
                            "type": "string",
                            "description": "Role ID (UUIDv7)",
                            "name": "id",
                            "in": "path",
                            "required": true
                        },
                        {
                            "description": "JSON:API Role update request",
                            "name": "request",
                            "in": "body",
                            "required": true,
                            "schema": {
                                "$ref": "#/definitions/dto.RoleUpdateRequest"
                            }
                        }
                    ],
                    "responses": {
                        "200": {
                            "description": "OK",
                            "schema": {
                                "$ref": "#/definitions/dto.RoleResponse"
                            }
                        },
                        "404": {
                            "description": "Not Found",
                            "schema": {
                                "$ref": "#/definitions/dto.ErrorResponse"
                            }
                        },
                        "422": {
                            "description": "Unprocessable Entity",
                            "schema": {
                                "$ref": "#/definitions/dto.ErrorResponse"
                            }
                        }
                    }
                },
                "delete": {
                    "produces": [
                        "application/json"
                    ],
                    "tags": [
                        "Roles"
                    ],
                    "summary": "Delete role",
                    "parameters": [
                        {
                            "type": "string",
                            "description": "Role ID (UUIDv7)",
                            "name": "id",
                            "in": "path",
                            "required": true
                        }
                    ],
                    "responses": {
                        "200": {
                            "description": "OK",
                            "schema": {
                                "$ref": "#/definitions/dto.VerifyEmailResponse"
                            }
                        },
                        "404": {
                            "description": "Not Found",
                            "schema": {
                                "$ref": "#/definitions/dto.ErrorResponse"
                            }
                        }
                    }
                }
            }
        },
        "definitions": {
            "dto.ErrorResponse": {
                "type": "object",
                "properties": {
                    "errors": {
                        "type": "array",
                        "items": {
                            "type": "object",
                            "properties": {
                                "detail": {
                                    "type": "string",
                                    "example": "email is required"
                                },
                                "source": {
                                    "type": "object",
                                    "properties": {
                                        "pointer": {
                                            "type": "string",
                                            "example": "/data/attributes/email"
                                        }
                                    }
                                },
                                "status": {
                                    "type": "string",
                                    "example": "422"
                                },
                                "title": {
                                    "type": "string",
                                    "example": "Validation error"
                                }
                            }
                        }
                    }
                }
            },
            "dto.LoginAttributes": {
                "type": "object",
                "required": [
                    "email",
                    "password"
                ],
                "properties": {
                    "email": {
                        "type": "string",
                        "example": "user@example.com"
                    },
                    "password": {
                        "type": "string",
                        "example": "StrongP@ssw0rd"
                    }
                }
            },
            "dto.LoginData": {
                "type": "object",
                "required": [
                    "attributes",
                    "type"
                ],
                "properties": {
                    "attributes": {
                        "$ref": "#/definitions/dto.LoginAttributes"
                    },
                    "type": {
                        "type": "string",
                        "example": "auth_login"
                    }
                }
            },
            "dto.LoginRequest": {
                "type": "object",
                "required": [
                    "data"
                ],
                "properties": {
                    "data": {
                        "$ref": "#/definitions/dto.LoginData"
                    }
                }
            },
            "dto.LoginTOTPRequiredResponse": {
                "type": "object",
                "properties": {
                    "data": {
                        "type": "object",
                        "properties": {
                            "attributes": {
                                "type": "object",
                                "properties": {
                                    "challenge_token": {
                                        "type": "string",
                                        "example": "kP8k2..."
                                    },
                                    "requires_totp": {
                                        "type": "boolean",
                                        "example": true
                                    }
                                }
                            },
                            "type": {
                                "type": "string",
                                "example": "auth_totp_required"
                            }
                        }
                    }
                }
            },
            "dto.MicrosoftCallbackResponse": {
                "type": "object",
                "properties": {
                    "data": {
                        "type": "object",
                        "properties": {
                            "attributes": {
                                "type": "object",
                                "properties": {
                                    "email": {
                                        "type": "string",
                                        "example": "user@example.com"
                                    },
                                    "provider": {
                                        "type": "string",
                                        "example": "microsoft"
                                    },
                                    "provider_subject": {
                                        "type": "string",
                                        "example": "00000000-0000-0000-0000-000000000000"
                                    }
                                }
                            },
                            "id": {
                                "type": "string",
                                "example": "018f3a5c-7a6f-7c2a-8d4a-4b5b1f6c8e9d"
                            },
                            "type": {
                                "type": "string",
                                "example": "auth_identity"
                            }
                        }
                    }
                }
            },
            "dto.MicrosoftLoginResponse": {
                "type": "object",
                "properties": {
                    "data": {
                        "type": "object",
                        "properties": {
                            "attributes": {
                                "type": "object",
                                "properties": {
                                    "url": {
                                        "type": "string",
                                        "example": "https://login.microsoftonline.com/..."
                                    }
                                }
                            },
                            "type": {
                                "type": "string",
                                "example": "auth_microsoft_url"
                            }
                        }
                    }
                }
            },
            "dto.RegisterAttributes": {
                "type": "object",
                "required": [
                    "email",
                    "first_name",
                    "last_name",
                    "password"
                ],
                "properties": {
                    "email": {
                        "type": "string",
                        "example": "user@example.com"
                    },
                    "first_name": {
                        "type": "string",
                        "example": "John"
                    },
                    "last_name": {
                        "type": "string",
                        "example": "Doe"
                    },
                    "mobile_phone": {
                        "type": "string",
                        "example": "+628123456789"
                    },
                    "password": {
                        "type": "string",
                        "minLength": 8,
                        "example": "StrongP@ssw0rd"
                    }
                }
            },
            "dto.RegisterData": {
                "type": "object",
                "required": [
                    "attributes",
                    "type"
                ],
                "properties": {
                    "attributes": {
                        "$ref": "#/definitions/dto.RegisterAttributes"
                    },
                    "type": {
                        "type": "string",
                        "example": "auth_register"
                    }
                }
            },
            "dto.RegisterRequest": {
                "type": "object",
                "required": [
                    "data"
                ],
                "properties": {
                    "data": {
                        "$ref": "#/definitions/dto.RegisterData"
                    }
                }
            },
            "dto.RoleAttributes": {
                "type": "object",
                "properties": {
                    "created_at": {
                        "type": "string",
                        "example": "2026-02-05T10:00:00Z"
                    },
                    "description": {
                        "type": "string",
                        "example": "Full access to all resources."
                    },
                    "name": {
                        "type": "string",
                        "example": "admin"
                    },
                    "updated_at": {
                        "type": "string",
                        "example": "2026-02-05T10:00:00Z"
                    }
                }
            },
            "dto.RoleCreateAttributes": {
                "type": "object",
                "required": [
                    "name"
                ],
                "properties": {
                    "description": {
                        "type": "string",
                        "example": "Full access to all resources."
                    },
                    "name": {
                        "type": "string",
                        "example": "admin"
                    }
                }
            },
            "dto.RoleCreateData": {
                "type": "object",
                "required": [
                    "attributes",
                    "type"
                ],
                "properties": {
                    "attributes": {
                        "$ref": "#/definitions/dto.RoleCreateAttributes"
                    },
                    "type": {
                        "type": "string",
                        "example": "role"
                    }
                }
            },
            "dto.RoleCreateRequest": {
                "type": "object",
                "required": [
                    "data"
                ],
                "properties": {
                    "data": {
                        "$ref": "#/definitions/dto.RoleCreateData"
                    }
                }
            },
            "dto.RoleDataTableResponse": {
                "type": "object",
                "properties": {
                    "data": {
                        "type": "array",
                        "items": {
                            "$ref": "#/definitions/dto.RoleResource"
                        }
                    },
                    "meta": {
                        "type": "object",
                        "properties": {
                            "draw": {
                                "type": "integer",
                                "example": 1
                            },
                            "records_filtered": {
                                "type": "integer",
                                "example": 12
                            },
                            "records_total": {
                                "type": "integer",
                                "example": 120
                            }
                        }
                    }
                }
            },
            "dto.RoleListResponse": {
                "type": "object",
                "properties": {
                    "data": {
                        "type": "array",
                        "items": {
                            "$ref": "#/definitions/dto.RoleResource"
                        }
                    },
                    "meta": {
                        "type": "object",
                        "properties": {
                            "page_number": {
                                "type": "integer",
                                "example": 1
                            },
                            "page_size": {
                                "type": "integer",
                                "example": 20
                            },
                            "total": {
                                "type": "integer",
                                "example": 120
                            }
                        }
                    }
                }
            },
            "dto.RoleMini": {
                "type": "object",
                "properties": {
                    "description": {
                        "type": "string",
                        "example": "Full access to all resources."
                    },
                    "id": {
                        "type": "string",
                        "example": "018f3a5c-7a6f-7c2a-8d4a-4b5b1f6c8e9d"
                    },
                    "name": {
                        "type": "string",
                        "example": "admin"
                    }
                }
            },
            "dto.RoleResource": {
                "type": "object",
                "properties": {
                    "attributes": {
                        "$ref": "#/definitions/dto.RoleAttributes"
                    },
                    "id": {
                        "type": "string",
                        "example": "018f3a5c-7a6f-7c2a-8d4a-4b5b1f6c8e9d"
                    },
                    "type": {
                        "type": "string",
                        "example": "role"
                    }
                }
            },
            "dto.RoleResponse": {
                "type": "object",
                "properties": {
                    "data": {
                        "$ref": "#/definitions/dto.RoleResource"
                    }
                }
            },
            "dto.RoleUpdateAttributes": {
                "type": "object",
                "required": [
                    "name"
                ],
                "properties": {
                    "description": {
                        "type": "string",
                        "example": "Full access to all resources."
                    },
                    "name": {
                        "type": "string",
                        "example": "admin"
                    }
                }
            },
            "dto.RoleUpdateData": {
                "type": "object",
                "required": [
                    "attributes",
                    "type"
                ],
                "properties": {
                    "attributes": {
                        "$ref": "#/definitions/dto.RoleUpdateAttributes"
                    },
                    "type": {
                        "type": "string",
                        "example": "role"
                    }
                }
            },
            "dto.RoleUpdateRequest": {
                "type": "object",
                "required": [
                    "data"
                ],
                "properties": {
                    "data": {
                        "$ref": "#/definitions/dto.RoleUpdateData"
                    }
                }
            },
            "dto.TOTPConfirmAttributes": {
                "type": "object",
                "required": [
                    "code",
                    "user_id"
                ],
                "properties": {
                    "code": {
                        "type": "string",
                        "example": "123456"
                    },
                    "user_id": {
                        "type": "string",
                        "example": "018f3a5c-7a6f-7c2a-8d4a-4b5b1f6c8e9d"
                    }
                }
            },
            "dto.TOTPConfirmData": {
                "type": "object",
                "required": [
                    "attributes",
                    "type"
                ],
                "properties": {
                    "attributes": {
                        "$ref": "#/definitions/dto.TOTPConfirmAttributes"
                    },
                    "type": {
                        "type": "string",
                        "example": "auth_totp_confirm"
                    }
                }
            },
            "dto.TOTPConfirmRequest": {
                "type": "object",
                "required": [
                    "data"
                ],
                "properties": {
                    "data": {
                        "$ref": "#/definitions/dto.TOTPConfirmData"
                    }
                }
            },
            "dto.TOTPDisableAttributes": {
                "type": "object",
                "required": [
                    "user_id"
                ],
                "properties": {
                    "user_id": {
                        "type": "string",
                        "example": "018f3a5c-7a6f-7c2a-8d4a-4b5b1f6c8e9d"
                    }
                }
            },
            "dto.TOTPDisableData": {
                "type": "object",
                "required": [
                    "attributes",
                    "type"
                ],
                "properties": {
                    "attributes": {
                        "$ref": "#/definitions/dto.TOTPDisableAttributes"
                    },
                    "type": {
                        "type": "string",
                        "example": "auth_totp_disable"
                    }
                }
            },
            "dto.TOTPDisableRequest": {
                "type": "object",
                "required": [
                    "data"
                ],
                "properties": {
                    "data": {
                        "$ref": "#/definitions/dto.TOTPDisableData"
                    }
                }
            },
            "dto.TOTPSetupAttributes": {
                "type": "object",
                "required": [
                    "email",
                    "user_id"
                ],
                "properties": {
                    "email": {
                        "type": "string",
                        "example": "user@example.com"
                    },
                    "user_id": {
                        "type": "string",
                        "example": "018f3a5c-7a6f-7c2a-8d4a-4b5b1f6c8e9d"
                    }
                }
            },
            "dto.TOTPSetupData": {
                "type": "object",
                "required": [
                    "attributes",
                    "type"
                ],
                "properties": {
                    "attributes": {
                        "$ref": "#/definitions/dto.TOTPSetupAttributes"
                    },
                    "type": {
                        "type": "string",
                        "example": "auth_totp_setup"
                    }
                }
            },
            "dto.TOTPSetupRequest": {
                "type": "object",
                "required": [
                    "data"
                ],
                "properties": {
                    "data": {
                        "$ref": "#/definitions/dto.TOTPSetupData"
                    }
                }
            },
            "dto.TOTPSetupResponse": {
                "type": "object",
                "properties": {
                    "data": {
                        "type": "object",
                        "properties": {
                            "attributes": {
                                "type": "object",
                                "properties": {
                                    "issuer": {
                                        "type": "string",
                                        "example": "Wucher"
                                    },
                                    "otpauth_url": {
                                        "type": "string",
                                        "example": "otpauth://totp/Wucher:user@example.com?..."
                                    },
                                    "secret": {
                                        "type": "string",
                                        "example": "JBSWY3DPEHPK3PXP"
                                    }
                                }
                            },
                            "type": {
                                "type": "string",
                                "example": "auth_totp_setup"
                            }
                        }
                    }
                }
            },
            "dto.TOTPVerifyAttributes": {
                "type": "object",
                "required": [
                    "challenge_token",
                    "code"
                ],
                "properties": {
                    "challenge_token": {
                        "type": "string",
                        "example": "kP8k2..."
                    },
                    "code": {
                        "type": "string",
                        "example": "123456"
                    }
                }
            },
            "dto.TOTPVerifyData": {
                "type": "object",
                "required": [
                    "attributes",
                    "type"
                ],
                "properties": {
                    "attributes": {
                        "$ref": "#/definitions/dto.TOTPVerifyAttributes"
                    },
                    "type": {
                        "type": "string",
                        "example": "auth_totp_verify"
                    }
                }
            },
            "dto.TOTPVerifyRequest": {
                "type": "object",
                "required": [
                    "data"
                ],
                "properties": {
                    "data": {
                        "$ref": "#/definitions/dto.TOTPVerifyData"
                    }
                }
            },
            "dto.TokenResponse": {
                "type": "object",
                "properties": {
                    "data": {
                        "type": "object",
                        "properties": {
                            "attributes": {
                                "type": "object",
                                "properties": {
                                    "access_expires_in": {
                                        "type": "integer",
                                        "example": 900
                                    },
                                    "refresh_expires_in": {
                                        "type": "integer",
                                        "example": 2592000
                                    }
                                }
                            },
                            "type": {
                                "type": "string",
                                "example": "auth_tokens"
                            }
                        }
                    }
                }
            },
            "dto.UserAttributes": {
                "type": "object",
                "properties": {
                    "email": {
                        "type": "string",
                        "example": "user@example.com"
                    },
                    "email_verified_at": {
                        "type": "string",
                        "example": "2026-02-05T10:00:00Z"
                    },
                    "first_name": {
                        "type": "string",
                        "example": "John"
                    },
                    "last_name": {
                        "type": "string",
                        "example": "Doe"
                    },
                    "mobile_phone": {
                        "type": "string",
                        "example": "+628123456789"
                    },
                    "role": {
                        "$ref": "#/definitions/dto.RoleMini"
                    },
                    "role_id": {
                        "type": "string",
                        "example": "018f3a5c-7a6f-7c2a-8d4a-4b5b1f6c8e9d"
                    }
                }
            },
            "dto.UserResponse": {
                "type": "object",
                "properties": {
                    "data": {
                        "type": "object",
                        "properties": {
                            "attributes": {
                                "$ref": "#/definitions/dto.UserAttributes"
                            },
                            "id": {
                                "type": "string",
                                "example": "018f3a5c-7a6f-7c2a-8d4a-4b5b1f6c8e9d"
                            },
                            "type": {
                                "type": "string",
                                "example": "user"
                            }
                        }
                    }
                }
            },
            "dto.VerifyEmailResponse": {
                "type": "object",
                "properties": {
                    "data": {
                        "type": "object",
                        "properties": {
                            "attributes": {
                                "type": "object",
                                "properties": {
                                    "verified": {
                                        "type": "boolean",
                                        "example": true
                                    }
                                }
                            },
                            "type": {
                                "type": "string",
                                "example": "auth_verify_email"
                            }
                        }
                    }
                }
            }
        }
    }`

    // SwaggerInfo holds exported Swagger Info so clients can modify it
    var SwaggerInfo = &swag.Spec{
            Version:          "1.0",
            Host:             "",
            BasePath:         "/",
            Schemes:          []string{},
            Title:            "Wucher API",
            Description:      "Wucher backend API (JSON:API).\n\nAuth Flow (Email/Password):\n1) POST /api/v1/auth/register\n2) Check email, open verify link (GET /api/v1/auth/verify-email)\n3) POST /api/v1/auth/login\n- If TOTP enabled: response 202 with challenge_token\n4) POST /api/v1/auth/totp/verify (challenge_token + code) -> cookies\n\nAuth Flow (TOTP setup):\n1) POST /api/v1/auth/totp/setup -> secret + otpauth_url (enabled=false)\n2) POST /api/v1/auth/totp/confirm -> enabled=true\n\nAuth Flow (Microsoft Entra SSO):\n1) GET /api/v1/auth/microsoft/login -> redirect to Microsoft\n2) Microsoft redirects to /api/v1/auth/microsoft/callback\n3) Backend sets cookies then redirects to AUTH_SSO_SUCCESS_REDIRECT",
            InfoInstanceName: "swagger",
            SwaggerTemplate:  docTemplate,
            LeftDelim:        "{{",
            RightDelim:       "}}",
    }

    func init() {
            swag.Register(SwaggerInfo.InstanceName(), SwaggerInfo)
    }

    package api

    import (
            "github.com/gofiber/fiber/v2"
            "github.com/gofiber/fiber/v2/middleware/logger"
            "github.com/gofiber/fiber/v2/middleware/recover"
            "github.com/gofiber/fiber/v2/middleware/requestid"
    )

    // UseDefaultMiddlewares applies baseline best-practice middleware.
    func UseDefaultMiddlewares(app *fiber.App) {
            app.Use(recover.New())
            app.Use(requestid.New())
            app.Use(logger.New())
    }

    package api

    import (
            "github.com/gofiber/fiber/v2"

            "wucher/internal/service"
            "wucher/internal/transport/http/handlers"
            "wucher/internal/transport/http/middleware"
    )

    func RegisterRoutes(
            app *fiber.App,
            userHandler *handlers.UserHandler,
            healthHandler *handlers.HealthHandler,
            authHandler *handlers.AuthHandler,
            authSvc *service.AuthService,
            roleHandler *handlers.RoleHandler,
    ) {
            app.Get("/health", healthHandler.Handle)

            api := app.Group("/api")
            v1 := api.Group("/v1")

            registerAuthRoutes(v1, authHandler, authSvc)
            registerUserRoutes(v1, userHandler)
            registerRoleRoutes(v1, roleHandler, middleware.AuthRequired(authSvc))
            registerSwaggerRoutes(app)
    }

    package api

    import (
            "github.com/gofiber/fiber/v2"

            "wucher/internal/service"
            "wucher/internal/transport/http/handlers"
            "wucher/internal/transport/http/middleware"
    )

    func registerAuthRoutes(v1 fiber.Router, authHandler *handlers.AuthHandler, authSvc *service.AuthService) {
            auth := v1.Group("/auth")
            auth.Post("/register", authHandler.Register)
            auth.Post("/login", authHandler.Login)
            auth.Post("/refresh", authHandler.Refresh)
            auth.Post("/logout", authHandler.Logout)
            auth.Get("/me", middleware.AuthRequired(authSvc), authHandler.Me)
            auth.Get("/verify-email", authHandler.VerifyEmail)
            auth.Get("/microsoft/login", authHandler.MicrosoftLogin)
            auth.Get("/microsoft/callback", authHandler.MicrosoftCallback)
            auth.Post("/totp/setup", authHandler.TOTPSetup)
            auth.Post("/totp/confirm", authHandler.TOTPConfirm)
            auth.Post("/totp/verify", authHandler.TOTPVerify)
            auth.Post("/totp/disable", authHandler.TOTPDisable)
    }

    package api

    import (
            "github.com/gofiber/fiber/v2"

            "wucher/internal/transport/http/handlers"
    )

    func registerRoleRoutes(v1 fiber.Router, roleHandler *handlers.RoleHandler, authMiddleware fiber.Handler) {
            roles := v1.Group("/roles", authMiddleware)
            roles.Post("/", roleHandler.Create)
            roles.Get("/", roleHandler.List)
            roles.Get("/datatable", roleHandler.ListDatatable)
            roles.Get("/:id", roleHandler.GetByID)
            roles.Put("/:id", roleHandler.Update)
            roles.Delete("/:id", roleHandler.Delete)
    }

    package api

    import (
            "github.com/gofiber/fiber/v2"
            "github.com/gofiber/swagger"
    )

    func registerSwaggerRoutes(app *fiber.App) {
            app.Get("/swagger/*", swagger.HandlerDefault)
    }

    package api

    import (
            "github.com/gofiber/fiber/v2"

            "wucher/internal/transport/http/handlers"
    )

    func registerUserRoutes(v1 fiber.Router, userHandler *handlers.UserHandler) {
            users := v1.Group("/users")
            users.Get("/", userHandler.List)
            users.Get("/:id", userHandler.Get)
    }

    package api

    import (
            "gorm.io/gorm"
            "gorm.io/gorm/clause"

            "wucher/internal/domain/auth"
    )

    // SeedRoles inserts default roles if they don't exist.
    func SeedRoles(db *gorm.DB) error {
            roles := []auth.Role{
                    {Name: "admin", Description: "Full access to all resources."},
                    {Name: "staff", Description: "Operational access with limited admin rights."},
                    {Name: "user", Description: "Regular end-user access."},
            }

            return db.Clauses(clause.OnConflict{
                    Columns:   []clause.Column{{Name: "name"}},
                    DoNothing: true,
            }).Create(&roles).Error
    }

    package api

    import (
            "context"
            "time"

            "github.com/gofiber/fiber/v2"
    )

    type Server struct {
            app *fiber.App
    }

    func NewServer(app *fiber.App) *Server {
            return &Server{
                    app: app,
            }
    }

    func (s *Server) Start(addr string) error {
            return s.app.Listen(addr)
    }

    func (s *Server) Shutdown(ctx context.Context) error {
            shutdownCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
            defer cancel()
            return s.app.ShutdownWithContext(shutdownCtx)
    }

    package worker

    type Runner struct{}

    func NewRunner() *Runner {
            return &Runner{}
    }

    func (r *Runner) Start() error {
            return nil
    }

    package config

    import (
            "os"
            "strconv"
            "strings"
            "time"
    )

    type MySQLConfig struct {
            DSN             string
            MaxOpenConns    int
            MaxIdleConns    int
            ConnMaxLifetime time.Duration
            ConnMaxIdleTime time.Duration
    }

    type AuthConfig struct {
            PasswordPepper   string
            HashTime         uint32
            HashMemoryKB     uint32
            HashThreads      uint8
            HashKeyLen       uint32
            EmailVerifyTTL   time.Duration
            EmailVerifyURL   string
            EmailQueueKey    string
            TOTPChallengeTTL time.Duration
            SSOStateTTL      time.Duration
            TOTPIssuer       string
            TOTPSecretKeyB64 string
            JWTAccessSecret  string
            JWTRefreshSecret string
            JWTAccessTTL     time.Duration
            JWTRefreshTTL    time.Duration
            JWTCookieDomain  string
            JWTCookieSecure  bool
            JWTCookieSameSite string
            JWTAccessCookieName  string
            JWTRefreshCookieName string
            SSOSuccessRedirectURL string
            DefaultRoleName       string
            DisableRegister       bool
    }

    type SMTPConfig struct {
            Host     string
            Port     int
            Username string
            Password string
            From     string
            InsecureSkipVerify bool
    }

    type MicrosoftSSOConfig struct {
            TenantID     string
            ClientID     string
            ClientSecret string
            RedirectURL  string
            Authority    string
            Scopes       []string
    }

    type RedisConfig struct {
            Addr     string
            Password string
            DB       int
    }

    func LoadMySQLConfigFromEnv() MySQLConfig {
            return MySQLConfig{
                    DSN:             getenv("MYSQL_DSN", ""),
                    MaxOpenConns:    getenvInt("MYSQL_MAX_OPEN_CONNS", 25),
                    MaxIdleConns:    getenvInt("MYSQL_MAX_IDLE_CONNS", 10),
                    ConnMaxLifetime: getenvDuration("MYSQL_CONN_MAX_LIFETIME", 5*time.Minute),
                    ConnMaxIdleTime: getenvDuration("MYSQL_CONN_MAX_IDLE_TIME", 2*time.Minute),
            }
    }

    func LoadAuthConfigFromEnv() AuthConfig {
            return AuthConfig{
                    PasswordPepper:   getenv("AUTH_PASSWORD_PEPPER", ""),
                    HashTime:         uint32(getenvInt("AUTH_HASH_TIME", 1)),
                    HashMemoryKB:     uint32(getenvInt("AUTH_HASH_MEMORY_KB", 64*1024)),
                    HashThreads:      uint8(getenvInt("AUTH_HASH_THREADS", 4)),
                    HashKeyLen:       uint32(getenvInt("AUTH_HASH_KEY_LEN", 32)),
                    EmailVerifyTTL:   getenvDuration("AUTH_EMAIL_VERIFY_TTL", 24*time.Hour),
                    EmailVerifyURL:   getenv("AUTH_EMAIL_VERIFY_URL", ""),
                    EmailQueueKey:    getenv("AUTH_EMAIL_QUEUE_KEY", "queue:email"),
                    TOTPChallengeTTL: getenvDuration("AUTH_TOTP_CHALLENGE_TTL", 5*time.Minute),
                    SSOStateTTL:      getenvDuration("AUTH_SSO_STATE_TTL", 10*time.Minute),
                    TOTPIssuer:       getenv("TOTP_ISSUER", "Wucher"),
                    TOTPSecretKeyB64: getenv("TOTP_SECRET_KEY", ""),
                    JWTAccessSecret:  getenv("AUTH_JWT_ACCESS_SECRET", ""),
                    JWTRefreshSecret: getenv("AUTH_JWT_REFRESH_SECRET", ""),
                    JWTAccessTTL:     getenvDuration("AUTH_JWT_ACCESS_TTL", 15*time.Minute),
                    JWTRefreshTTL:    getenvDuration("AUTH_JWT_REFRESH_TTL", 30*24*time.Hour),
                    JWTCookieDomain:  getenv("AUTH_JWT_COOKIE_DOMAIN", ""),
                    JWTCookieSecure:  getenv("AUTH_JWT_COOKIE_SECURE", "false") == "true",
                    JWTCookieSameSite: getenv("AUTH_JWT_COOKIE_SAMESITE", "Lax"),
                    JWTAccessCookieName:  getenv("AUTH_JWT_ACCESS_COOKIE", "wucher_at"),
                    JWTRefreshCookieName: getenv("AUTH_JWT_REFRESH_COOKIE", "wucher_rt"),
                    SSOSuccessRedirectURL: getenv("AUTH_SSO_SUCCESS_REDIRECT", ""),
                    DefaultRoleName:       getenv("AUTH_DEFAULT_ROLE", "admin"),
                    DisableRegister:       getenv("AUTH_DISABLE_REGISTER", "false") == "true",
            }
    }

    func LoadSMTPConfigFromEnv() SMTPConfig {
            return SMTPConfig{
                    Host:     getenv("SMTP_HOST", ""),
                    Port:     getenvInt("SMTP_PORT", 587),
                    Username: getenv("SMTP_USERNAME", ""),
                    Password: getenv("SMTP_PASSWORD", ""),
                    From:     getenv("SMTP_FROM", ""),
                    InsecureSkipVerify: getenv("SMTP_INSECURE_SKIP_VERIFY", "false") == "true",
            }
    }

    func LoadMicrosoftSSOConfigFromEnv() MicrosoftSSOConfig {
            scopes := strings.Split(getenv("MS_ENTRA_SCOPES", "openid,profile,email"), ",")
            for i := range scopes {
                    scopes[i] = strings.TrimSpace(scopes[i])
            }
            return MicrosoftSSOConfig{
                    TenantID:     getenv("MS_ENTRA_TENANT_ID", ""),
                    ClientID:     getenv("MS_ENTRA_CLIENT_ID", ""),
                    ClientSecret: getenv("MS_ENTRA_CLIENT_SECRET", ""),
                    RedirectURL:  getenv("MS_ENTRA_REDIRECT_URL", ""),
                    Authority:    getenv("MS_ENTRA_AUTHORITY", "https://login.microsoftonline.com/"),
                    Scopes:       scopes,
            }
    }

    func LoadRedisConfigFromEnv() RedisConfig {
            return RedisConfig{
                    Addr:     getenv("REDIS_ADDR", ""),
                    Password: getenv("REDIS_PASSWORD", ""),
                    DB:       getenvInt("REDIS_DB", 0),
            }
    }

    func getenv(key, def string) string {
            if v := os.Getenv(key); v != "" {
                    return v
            }
            return def
    }

    func getenvInt(key string, def int) int {
            if v := os.Getenv(key); v != "" {
                    if n, err := strconv.Atoi(v); err == nil {
                            return n
                    }
            }
            return def
    }

    func getenvDuration(key string, def time.Duration) time.Duration {
            if v := os.Getenv(key); v != "" {
                    if d, err := time.ParseDuration(v); err == nil {
                            return d
                    }
            }
            return def
    }

    package auth

    import (
            "time"

            "gorm.io/gorm"

            "wucher/internal/pkg/uuidv7"
    )

    type UserIdentity struct {
            ID              []byte `gorm:"type:binary(16);primaryKey;column:id"`
            UserID          []byte `gorm:"type:binary(16);index;not null;column:user_id"`
            Provider        string `gorm:"type:varchar(32);uniqueIndex:ux_provider_subject;not null;column:provider"`
            ProviderSubject string `gorm:"type:varchar(191);uniqueIndex:ux_provider_subject;not null;column:provider_subject"`
            Email           string `gorm:"type:varchar(191);index;column:email"`
            Metadata        []byte `gorm:"type:json;column:metadata"`
            CreatedAt       time.Time
            CreatedBy       []byte `gorm:"type:binary(16);column:created_by"`
            UpdatedAt       time.Time
            UpdatedBy       []byte `gorm:"type:binary(16);column:updated_by"`
    }

    func (UserIdentity) TableName() string { return "user_identities" }

    func (ui *UserIdentity) BeforeCreate(tx *gorm.DB) error {
            if len(ui.ID) == 0 {
                    ui.ID = uuidv7.MustBytes()
            }
            return nil
    }

    package auth

    import (
            "time"

            "gorm.io/gorm"

            "wucher/internal/pkg/uuidv7"
    )

    type Permission struct {
            ID          []byte `gorm:"type:binary(16);primaryKey;column:id"`
            Name        string `gorm:"type:varchar(64);uniqueIndex;not null;column:name"`
            Key         string `gorm:"type:varchar(64);uniqueIndex;not null;column:key"`
            Description string `gorm:"type:varchar(255);column:description"`
            CreatedAt   time.Time
            CreatedBy   []byte `gorm:"type:binary(16);column:created_by"`
            UpdatedAt   time.Time
            UpdatedBy   []byte `gorm:"type:binary(16);column:updated_by"`
    }

    func (Permission) TableName() string { return "permissions" }

    func (p *Permission) BeforeCreate(tx *gorm.DB) error {
            if len(p.ID) == 0 {
                    p.ID = uuidv7.MustBytes()
            }
            return nil
    }

    package auth

    import (
            "time"

            "gorm.io/gorm"

            "wucher/internal/pkg/uuidv7"
    )

    type Role struct {
            ID          []byte `gorm:"type:binary(16);primaryKey;column:id"`
            Name        string `gorm:"type:varchar(64);uniqueIndex;not null;column:name"`
            Description string `gorm:"type:varchar(255);column:description"`
            CreatedAt   time.Time
            CreatedBy   []byte `gorm:"type:binary(16);column:created_by"`
            UpdatedAt   time.Time
            UpdatedBy   []byte `gorm:"type:binary(16);column:updated_by"`
    }

    func (Role) TableName() string { return "roles" }

    func (r *Role) BeforeCreate(tx *gorm.DB) error {
            if len(r.ID) == 0 {
                    r.ID = uuidv7.MustBytes()
            }
            return nil
    }

    package auth

    import (
            "time"

            "gorm.io/gorm"

            "wucher/internal/pkg/uuidv7"
    )

    type RolePermission struct {
            ID           []byte `gorm:"type:binary(16);primaryKey;column:id"`
            RoleID       []byte `gorm:"type:binary(16);index;uniqueIndex:ux_role_permission;not null;column:role_id"`
            PermissionID []byte `gorm:"type:binary(16);index;uniqueIndex:ux_role_permission;not null;column:permission_id"`
            CreatedAt    time.Time
            CreatedBy    []byte `gorm:"type:binary(16);column:created_by"`
            UpdatedAt    time.Time
            UpdatedBy    []byte `gorm:"type:binary(16);column:updated_by"`
    }

    func (RolePermission) TableName() string { return "role_permissions" }

    func (rp *RolePermission) BeforeCreate(tx *gorm.DB) error {
            if len(rp.ID) == 0 {
                    rp.ID = uuidv7.MustBytes()
            }
            return nil
    }

    package auth

    import (
            "time"

            "gorm.io/gorm"

            "wucher/internal/pkg/uuidv7"
    )

    type UserTOTP struct {
            ID              []byte     `gorm:"type:binary(16);primaryKey;column:id"`
            UserID          []byte     `gorm:"type:binary(16);uniqueIndex;not null;column:user_id"`
            SecretEncrypted []byte     `gorm:"type:varbinary(512);not null;column:secret_encrypted"`
            Enabled         bool       `gorm:"type:tinyint(1);not null;default:1;column:enabled"`
            LastUsedAt      *time.Time `gorm:"column:last_used_at"`
            CreatedAt       time.Time
            CreatedBy       []byte `gorm:"type:binary(16);column:created_by"`
            UpdatedAt       time.Time
            UpdatedBy       []byte `gorm:"type:binary(16);column:updated_by"`
    }

    func (UserTOTP) TableName() string { return "user_totp" }

    func (ut *UserTOTP) BeforeCreate(tx *gorm.DB) error {
            if len(ut.ID) == 0 {
                    ut.ID = uuidv7.MustBytes()
            }
            return nil
    }

    package auth

    import (
            "time"

            "gorm.io/gorm"

            "wucher/internal/pkg/uuidv7"
    )

    // Note: UUIDv7 should be generated at application layer and stored as BINARY(16).
    type User struct {
            ID           []byte `gorm:"type:binary(16);primaryKey;column:id"`
            Email        string `gorm:"type:varchar(191);uniqueIndex;not null;column:email"`
            FirstName    string `gorm:"type:varchar(100);not null;column:first_name"`
            LastName     string `gorm:"type:varchar(100);not null;column:last_name"`
            MobilePhone  string `gorm:"type:varchar(32);uniqueIndex;column:mobile_phone"`
            RoleID       []byte `gorm:"type:binary(16);index;not null;column:role_id"`
            PasswordHash []byte `gorm:"type:varbinary(255);column:password_hash"`
            SaltValue    []byte `gorm:"type:varbinary(255);column:salt_value"`
            EmailVerifiedAt *time.Time `gorm:"column:email_verified_at"`
            CreatedAt    time.Time
            CreatedBy    []byte `gorm:"type:binary(16);column:created_by"`
            UpdatedAt    time.Time
            UpdatedBy    []byte `gorm:"type:binary(16);column:updated_by"`
    }

    func (User) TableName() string { return "users" }

    func (u *User) BeforeCreate(tx *gorm.DB) error {
            if len(u.ID) == 0 {
                    u.ID = uuidv7.MustBytes()
            }
            return nil
    }

    package logger

    import "log"

    func New() *log.Logger {
            return log.Default()
    }

    package uuidv7

    import (
            "crypto/rand"
            "encoding/hex"
            "errors"
            "time"
    )

    // New returns a UUIDv7 (RFC 9562) as a 16-byte array.
    func New() ([16]byte, error) {
            var out [16]byte

            var r [10]byte
            if _, err := rand.Read(r[:]); err != nil {
                    return out, err
            }

            // 48-bit Unix epoch milliseconds
            ms := uint64(time.Now().UnixNano() / int64(time.Millisecond))
            out[0] = byte(ms >> 40)
            out[1] = byte(ms >> 32)
            out[2] = byte(ms >> 24)
            out[3] = byte(ms >> 16)
            out[4] = byte(ms >> 8)
            out[5] = byte(ms)

            // Version (0111) in high 4 bits of byte 6
            out[6] = (r[0] & 0x0f) | 0x70
            out[7] = r[1]

            // Variant (10) in high 2 bits of byte 8
            out[8] = (r[2] & 0x3f) | 0x80
            out[9] = r[3]
            out[10] = r[4]
            out[11] = r[5]
            out[12] = r[6]
            out[13] = r[7]
            out[14] = r[8]
            out[15] = r[9]

            return out, nil
    }

    // MustNew panics if UUID generation fails.
    func MustNew() [16]byte {
            u, err := New()
            if err != nil {
                    panic(err)
            }
            return u
    }

    // Bytes returns a new UUIDv7 as []byte for BINARY(16) columns.
    func Bytes() ([]byte, error) {
            u, err := New()
            if err != nil {
                    return nil, err
            }
            b := make([]byte, 16)
            copy(b, u[:])
            return b, nil
    }

    // MustBytes panics if UUID generation fails.
    func MustBytes() []byte {
            u := MustNew()
            b := make([]byte, 16)
            copy(b, u[:])
            return b
    }

    // String formats a UUID as a canonical string (8-4-4-4-12).
    func String(u [16]byte) (string, error) {
            if len(u) != 16 {
                    return "", errors.New("invalid uuid length")
            }
            dst := make([]byte, 36)
            hex.Encode(dst[0:8], u[0:4])
            dst[8] = '-'
            hex.Encode(dst[9:13], u[4:6])
            dst[13] = '-'
            hex.Encode(dst[14:18], u[6:8])
            dst[18] = '-'
            hex.Encode(dst[19:23], u[8:10])
            dst[23] = '-'
            hex.Encode(dst[24:36], u[10:16])
            return string(dst), nil
    }

    // BytesToString formats a UUID stored in []byte as a canonical string.
    func BytesToString(b []byte) (string, error) {
            if len(b) != 16 {
                    return "", errors.New("invalid uuid length")
            }
            var u [16]byte
            copy(u[:], b)
            return String(u)
    }

    // ParseString parses a canonical UUID string into []byte (16 bytes).
    func ParseString(s string) ([]byte, error) {
            if len(s) != 36 {
                    return nil, errors.New("invalid uuid string length")
            }
            buf := make([]byte, 16)
            _, err := hex.Decode(buf[0:4], []byte(s[0:8]))
            if err != nil {
                    return nil, err
            }
            _, err = hex.Decode(buf[4:6], []byte(s[9:13]))
            if err != nil {
                    return nil, err
            }
            _, err = hex.Decode(buf[6:8], []byte(s[14:18]))
            if err != nil {
                    return nil, err
            }
            _, err = hex.Decode(buf[8:10], []byte(s[19:23]))
            if err != nil {
                    return nil, err
            }
            _, err = hex.Decode(buf[10:16], []byte(s[24:36]))
            if err != nil {
                    return nil, err
            }
            return buf, nil
    }

    package mysql

    import (
            "context"
            "errors"
            "time"

            "gorm.io/gorm"
            "gorm.io/gorm/clause"

            "wucher/internal/domain/auth"
    )

    type AuthRepository struct {
            db *gorm.DB
    }

    func NewAuthRepository(db *gorm.DB) *AuthRepository {
            return &AuthRepository{db: db}
    }

    // Users

    func (r *AuthRepository) CreateUser(ctx context.Context, user *auth.User) error {
            return r.db.WithContext(ctx).Create(user).Error
    }

    func (r *AuthRepository) GetUserByID(ctx context.Context, id []byte) (*auth.User, error) {
            var user auth.User
            err := r.db.WithContext(ctx).First(&user, "id = ?", id).Error
            if errors.Is(err, gorm.ErrRecordNotFound) {
                    return nil, nil
            }
            return &user, err
    }

    func (r *AuthRepository) GetUserByEmail(ctx context.Context, email string) (*auth.User, error) {
            var user auth.User
            err := r.db.WithContext(ctx).Where("email = ?", email).First(&user).Error
            if errors.Is(err, gorm.ErrRecordNotFound) {
                    return nil, nil
            }
            return &user, err
    }

    func (r *AuthRepository) MarkEmailVerified(ctx context.Context, id []byte) error {
            now := time.Now()
            return r.db.WithContext(ctx).
                    Model(&auth.User{}).
                    Where("id = ?", id).
                    Update("email_verified_at", &now).Error
    }

    // Identities (SSO)

    func (r *AuthRepository) UpsertIdentity(ctx context.Context, identity *auth.UserIdentity) error {
            // Ensure we keep a single row per provider+subject
            return r.db.WithContext(ctx).
                    Clauses(clause.OnConflict{
                            Columns:   []clause.Column{{Name: "provider"}, {Name: "provider_subject"}},
                            DoUpdates: clause.AssignmentColumns([]string{"user_id", "email", "metadata", "updated_at", "updated_by"}),
                    }).
                    Create(identity).Error
    }

    func (r *AuthRepository) GetIdentityByProviderSubject(ctx context.Context, provider, subject string) (*auth.UserIdentity, error) {
            var identity auth.UserIdentity
            err := r.db.WithContext(ctx).
                    Where("provider = ? AND provider_subject = ?", provider, subject).
                    First(&identity).Error
            if errors.Is(err, gorm.ErrRecordNotFound) {
                    return nil, nil
            }
            return &identity, err
    }

    // TOTP

    func (r *AuthRepository) GetUserTOTP(ctx context.Context, userID []byte) (*auth.UserTOTP, error) {
            var totp auth.UserTOTP
            err := r.db.WithContext(ctx).Where("user_id = ?", userID).First(&totp).Error
            if errors.Is(err, gorm.ErrRecordNotFound) {
                    return nil, nil
            }
            return &totp, err
    }

    func (r *AuthRepository) UpsertUserTOTP(ctx context.Context, totp *auth.UserTOTP) error {
            return r.db.WithContext(ctx).
                    Clauses(clause.OnConflict{
                            Columns:   []clause.Column{{Name: "user_id"}},
                            DoUpdates: clause.AssignmentColumns([]string{"secret_encrypted", "enabled", "updated_at", "updated_by"}),
                    }).
                    Create(totp).Error
    }

    func (r *AuthRepository) DisableUserTOTP(ctx context.Context, userID []byte) error {
            return r.db.WithContext(ctx).
                    Model(&auth.UserTOTP{}).
                    Where("user_id = ?", userID).
                    Updates(map[string]any{
                            "enabled":    false,
                            "updated_at": time.Now(),
                    }).Error
    }

    func (r *AuthRepository) UpdateUserTOTPLastUsed(ctx context.Context, userID []byte) error {
            return r.db.WithContext(ctx).
                    Model(&auth.UserTOTP{}).
                    Where("user_id = ? AND enabled = 1", userID).
                    Update("last_used_at", time.Now()).Error
    }

    package mysql

    import (
            "errors"

            "gorm.io/driver/mysql"
            "gorm.io/gorm"
            "gorm.io/gorm/logger"

            "wucher/internal/config"
            "wucher/internal/domain/auth"
    )

    func Connect(cfg config.MySQLConfig) (*gorm.DB, error) {
            if cfg.DSN == "" {
                    return nil, errors.New("MYSQL_DSN is required")
            }

            db, err := gorm.Open(mysql.Open(cfg.DSN), &gorm.Config{
                    Logger: logger.Default.LogMode(logger.Warn),
            })
            if err != nil {
                    return nil, err
            }

            sqlDB, err := db.DB()
            if err != nil {
                    return nil, err
            }
            sqlDB.SetMaxOpenConns(cfg.MaxOpenConns)
            sqlDB.SetMaxIdleConns(cfg.MaxIdleConns)
            sqlDB.SetConnMaxLifetime(cfg.ConnMaxLifetime)
            sqlDB.SetConnMaxIdleTime(cfg.ConnMaxIdleTime)

            return db, nil
    }

    func AutoMigrate(db *gorm.DB) error {
            return db.AutoMigrate(
                    &auth.Role{},
                    &auth.Permission{},
                    &auth.User{},
                    &auth.RolePermission{},
                    &auth.UserIdentity{},
                    &auth.UserTOTP{},
            )
    }

    package mysql

    import (
            "context"
            "errors"
            "strings"

            "gorm.io/gorm"

            "wucher/internal/domain/auth"
    )

    type RoleRepository struct {
            db *gorm.DB
    }

    func NewRoleRepository(db *gorm.DB) *RoleRepository {
            return &RoleRepository{db: db}
    }

    func (r *RoleRepository) CreateRole(ctx context.Context, role *auth.Role) error {
            return r.db.WithContext(ctx).Create(role).Error
    }

    func (r *RoleRepository) UpdateRole(ctx context.Context, role *auth.Role) error {
            return r.db.WithContext(ctx).Save(role).Error
    }

    func (r *RoleRepository) DeleteRole(ctx context.Context, id []byte) error {
            return r.db.WithContext(ctx).Delete(&auth.Role{}, "id = ?", id).Error
    }

    func (r *RoleRepository) GetRoleByID(ctx context.Context, id []byte) (*auth.Role, error) {
            var role auth.Role
            err := r.db.WithContext(ctx).First(&role, "id = ?", id).Error
            if errors.Is(err, gorm.ErrRecordNotFound) {
                    return nil, nil
            }
            return &role, err
    }

    func (r *RoleRepository) GetRoleByName(ctx context.Context, name string) (*auth.Role, error) {
            var role auth.Role
            err := r.db.WithContext(ctx).Where("name = ?", name).First(&role).Error
            if errors.Is(err, gorm.ErrRecordNotFound) {
                    return nil, nil
            }
            return &role, err
    }

    func (r *RoleRepository) ListRoles(ctx context.Context, filterName, sort string, limit, offset int) ([]auth.Role, int64, error) {
            var roles []auth.Role
            var total int64

            query := r.db.WithContext(ctx).Model(&auth.Role{})

            if filterName != "" {
                    query = query.Where("LOWER(name) LIKE ?", "%"+strings.ToLower(filterName)+"%")
            }

            if err := query.Count(&total).Error; err != nil {
                    return nil, 0, err
            }

            if sort != "" {
                    query = query.Order(sort)
            } else {
                    query = query.Order("created_at DESC")
            }

            if limit > 0 {
                    query = query.Limit(limit).Offset(offset)
            }

            if err := query.Find(&roles).Error; err != nil {
                    return nil, 0, err
            }

            return roles, total, nil
    }

    package mysql

    import (
            "context"
            "errors"

            "gorm.io/gorm"

            "wucher/internal/domain/user"
    )

    type UserRepository struct {
            db *gorm.DB
    }

    func NewUserRepository(db *gorm.DB) *UserRepository {
            return &UserRepository{db: db}
    }

    func (r *UserRepository) Create(ctx context.Context, u *user.User) error {
            return r.db.WithContext(ctx).Create(u).Error
    }

    func (r *UserRepository) GetByID(ctx context.Context, id []byte) (*user.User, error) {
            var u user.User
            err := r.db.WithContext(ctx).First(&u, "id = ?", id).Error
            if errors.Is(err, gorm.ErrRecordNotFound) {
                    return nil, nil
            }
            return &u, err
    }

    func (r *UserRepository) GetByEmail(ctx context.Context, email string) (*user.User, error) {
            var u user.User
            err := r.db.WithContext(ctx).Where("email = ?", email).First(&u).Error
            if errors.Is(err, gorm.ErrRecordNotFound) {
                    return nil, nil
            }
            return &u, err
    }

    package redis

    import (
            "context"
            "time"

            "github.com/redis/go-redis/v9"

            "wucher/internal/service"
    )

    type EmailQueue struct {
            client *redis.Client
            key    string
    }

    func NewEmailQueue(client *redis.Client, key string) *EmailQueue {
            return &EmailQueue{client: client, key: key}
    }

    func (q *EmailQueue) Enqueue(ctx context.Context, job service.EmailJob) error {
            b, err := service.EncodeEmailJob(job)
            if err != nil {
                    return err
            }
            return q.client.RPush(ctx, q.key, b).Err()
    }

    func (q *EmailQueue) Dequeue(ctx context.Context) (*service.EmailJob, error) {
            res, err := q.client.BLPop(ctx, 10*time.Second, q.key).Result()
            if err == redis.Nil {
                    return nil, nil
            }
            if err != nil {
                    return nil, err
            }
            if len(res) < 2 {
                    return nil, nil
            }
            return service.DecodeEmailJob([]byte(res[1]))
    }

    package redis

    import (
            "context"

            "github.com/redis/go-redis/v9"

            "wucher/internal/config"
    )

    func NewClient(cfg config.RedisConfig) *redis.Client {
            return redis.NewClient(&redis.Options{
                    Addr:     cfg.Addr,
                    Password: cfg.Password,
                    DB:       cfg.DB,
            })
    }

    func Ping(ctx context.Context, client *redis.Client) error {
            return client.Ping(ctx).Err()
    }

    package redis

    import (
            "context"
            "time"

            "github.com/redis/go-redis/v9"
    )

    type TokenStore struct {
            client *redis.Client
    }

    func NewTokenStore(client *redis.Client) *TokenStore {
            return &TokenStore{client: client}
    }

    func (s *TokenStore) Set(ctx context.Context, key string, value []byte, ttl time.Duration) error {
            return s.client.Set(ctx, key, value, ttl).Err()
    }

    func (s *TokenStore) Get(ctx context.Context, key string) ([]byte, error) {
            val, err := s.client.Get(ctx, key).Bytes()
            if err == redis.Nil {
                    return nil, nil
            }
            return val, err
    }

    func (s *TokenStore) Delete(ctx context.Context, key string) error {
            return s.client.Del(ctx, key).Err()
    }

    package service

    import (
            "context"
            "crypto/rand"
            "encoding/base64"
            "errors"
            "strings"
            "time"

            "github.com/golang-jwt/jwt/v5"
            "github.com/pquerna/otp"
            "github.com/pquerna/otp/totp"
            "golang.org/x/crypto/argon2"

            "wucher/internal/config"
            "wucher/internal/domain/auth"
            "wucher/internal/pkg/uuidv7"
    )

    var (
            ErrInvalidCredentials = errors.New("invalid credentials")
            ErrEmailNotVerified   = errors.New("email not verified")
            ErrInvalidToken       = errors.New("invalid or expired token")
            ErrTOTPRequired       = errors.New("totp required")
    )

    type EmailSender interface {
            Send(ctx context.Context, to, subject, body string) error
    }

    type TokenStore interface {
            Set(ctx context.Context, key string, value []byte, ttl time.Duration) error
            Get(ctx context.Context, key string) ([]byte, error)
            Delete(ctx context.Context, key string) error
    }

    type SecretProtector interface {
            Encrypt(plain []byte) ([]byte, error)
            Decrypt(cipher []byte) ([]byte, error)
    }

    type MicrosoftSSO interface {
            AuthCodeURL(state string) string
            ExchangeCode(ctx context.Context, code string) (MicrosoftClaims, error)
    }

    type MicrosoftClaims struct {
            Subject string
            Email   string
            Name    string
    }

    type AuthService struct {
            repo      auth.Repository
            roleRepo  auth.RoleRepository
            tokens    TokenStore
            emailQueue EmailQueue
            mailer    EmailSender
            sso       MicrosoftSSO
            protector SecretProtector
            cfg       config.AuthConfig
    }

    func NewAuthService(
            repo auth.Repository,
            roleRepo auth.RoleRepository,
            tokens TokenStore,
            emailQueue EmailQueue,
            mailer EmailSender,
            sso MicrosoftSSO,
            protector SecretProtector,
            cfg config.AuthConfig,
    ) *AuthService {
            return &AuthService{
                    repo:      repo,
                    roleRepo:  roleRepo,
                    tokens:    tokens,
                    emailQueue: emailQueue,
                    mailer:    mailer,
                    sso:       sso,
                    protector: protector,
                    cfg:       cfg,
            }
    }

    func (s *AuthService) BeginMicrosoftLogin(ctx context.Context) (string, error) {
            if s.sso == nil || s.tokens == nil {
                    return "", errors.New("sso not configured")
            }
            state, err := randomToken(32)
            if err != nil {
                    return "", err
            }
            if err := s.tokens.Set(ctx, ssoStateKey(state), []byte("1"), s.cfg.SSOStateTTL); err != nil {
                    return "", err
            }
            return s.sso.AuthCodeURL(state), nil
    }

    func (s *AuthService) CompleteMicrosoftLogin(ctx context.Context, code, state string) (*auth.UserIdentity, error) {
            if s.sso == nil || s.tokens == nil {
                    return nil, errors.New("sso not configured")
            }
            if state == "" {
                    return nil, ErrInvalidToken
            }
            v, err := s.tokens.Get(ctx, ssoStateKey(state))
            if err != nil || v == nil {
                    return nil, ErrInvalidToken
            }
            _ = s.tokens.Delete(ctx, ssoStateKey(state))
            return s.LoginWithMicrosoft(ctx, code)
    }

    // RegisterWithEmail creates a user and sends a verification link.
    func (s *AuthService) RegisterWithEmail(ctx context.Context, email, firstName, lastName, mobilePhone, password string, roleID []byte) (*auth.User, error) {
            email = strings.ToLower(strings.TrimSpace(email))
            if email == "" || password == "" {
                    return nil, errors.New("email and password are required")
            }

            hash, salt, err := s.hashPassword(password)
            if err != nil {
                    return nil, err
            }

            user := &auth.User{
                    ID:           uuidv7.MustBytes(),
                    Email:        email,
                    FirstName:    firstName,
                    LastName:     lastName,
                    MobilePhone:  mobilePhone,
                    RoleID:       roleID,
                    PasswordHash: hash,
                    SaltValue:    salt,
            }

            if err := s.repo.CreateUser(ctx, user); err != nil {
                    return nil, err
            }

            if err := s.sendEmailVerification(ctx, user); err != nil {
                    return nil, err
            }

            return user, nil
    }

    func (s *AuthService) GetRoleIDByName(ctx context.Context, name string) ([]byte, error) {
            if s.roleRepo == nil {
                    return nil, errors.New("role repo not configured")
            }
            role, err := s.roleRepo.GetRoleByName(ctx, name)
            if err != nil || role == nil {
                    return nil, errors.New("role not found")
            }
            return role.ID, nil
    }

    func (s *AuthService) DefaultRoleName() string { return s.cfg.DefaultRoleName }
    func (s *AuthService) RegisterDisabled() bool  { return s.cfg.DisableRegister }

    // LoginWithEmailPassword verifies email/password and returns the user.
    func (s *AuthService) LoginWithEmailPassword(ctx context.Context, email, password string) (*auth.User, error) {
            email = strings.ToLower(strings.TrimSpace(email))
            user, err := s.repo.GetUserByEmail(ctx, email)
            if err != nil || user == nil {
                    return nil, ErrInvalidCredentials
            }

            if !s.verifyPassword(password, user.PasswordHash, user.SaltValue) {
                    return nil, ErrInvalidCredentials
            }

            if user.EmailVerifiedAt == nil {
                    return nil, ErrEmailNotVerified
            }

            return user, nil
    }

    func (s *AuthService) IsTOTPEnabled(ctx context.Context, userID []byte) (bool, error) {
            record, err := s.repo.GetUserTOTP(ctx, userID)
            if err != nil || record == nil {
                    return false, err
            }
            return record.Enabled, nil
    }

    type TokenPair struct {
            AccessToken  string
            RefreshToken string
    }

    func (s *AuthService) IssueTokens(ctx context.Context, user *auth.User) (TokenPair, error) {
            if s.cfg.JWTAccessSecret == "" || s.cfg.JWTRefreshSecret == "" {
                    return TokenPair{}, errors.New("jwt secrets not configured")
            }

            now := time.Now()
            accessClaims := jwt.MapClaims{
                    "sub":  bytesToString(user.ID),
                    "iat":  now.Unix(),
                    "exp":  now.Add(s.cfg.JWTAccessTTL).Unix(),
                    "type": "access",
            }
            accessToken, err := jwt.NewWithClaims(jwt.SigningMethodHS256, accessClaims).SignedString([]byte(s.cfg.JWTAccessSecret))
            if err != nil {
                    return TokenPair{}, err
            }

            jti, err := randomToken(32)
            if err != nil {
                    return TokenPair{}, err
            }

            refreshClaims := jwt.MapClaims{
                    "sub":  bytesToString(user.ID),
                    "iat":  now.Unix(),
                    "exp":  now.Add(s.cfg.JWTRefreshTTL).Unix(),
                    "jti":  jti,
                    "type": "refresh",
            }
            refreshToken, err := jwt.NewWithClaims(jwt.SigningMethodHS256, refreshClaims).SignedString([]byte(s.cfg.JWTRefreshSecret))
            if err != nil {
                    return TokenPair{}, err
            }

            // Store refresh token jti to allow revoke
            if s.tokens != nil {
                    _ = s.tokens.Set(ctx, refreshKey(jti), []byte("1"), s.cfg.JWTRefreshTTL)
            }

            return TokenPair{AccessToken: accessToken, RefreshToken: refreshToken}, nil
    }

    func (s *AuthService) RefreshTokens(ctx context.Context, refreshToken string) (TokenPair, error) {
            if refreshToken == "" {
                    return TokenPair{}, ErrInvalidToken
            }
            parsed, err := jwt.Parse(refreshToken, func(t *jwt.Token) (any, error) {
                    if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
                            return nil, ErrInvalidToken
                    }
                    return []byte(s.cfg.JWTRefreshSecret), nil
            })
            if err != nil || !parsed.Valid {
                    return TokenPair{}, ErrInvalidToken
            }

            claims, ok := parsed.Claims.(jwt.MapClaims)
            if !ok {
                    return TokenPair{}, ErrInvalidToken
            }
            if claims["type"] != "refresh" {
                    return TokenPair{}, ErrInvalidToken
            }
            jti, _ := claims["jti"].(string)
            sub, _ := claims["sub"].(string)
            if jti == "" || sub == "" {
                    return TokenPair{}, ErrInvalidToken
            }
            if s.tokens != nil {
                    v, err := s.tokens.Get(ctx, refreshKey(jti))
                    if err != nil || v == nil {
                            return TokenPair{}, ErrInvalidToken
                    }
                    _ = s.tokens.Delete(ctx, refreshKey(jti))
            }

            userID, err := uuidv7.ParseString(sub)
            if err != nil {
                    return TokenPair{}, ErrInvalidToken
            }
            user, err := s.repo.GetUserByID(ctx, userID)
            if err != nil || user == nil {
                    return TokenPair{}, ErrInvalidToken
            }
            return s.IssueTokens(ctx, user)
    }

    func (s *AuthService) RevokeRefreshToken(ctx context.Context, refreshToken string) error {
            if refreshToken == "" {
                    return nil
            }
            parsed, err := jwt.Parse(refreshToken, func(t *jwt.Token) (any, error) {
                    if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
                            return nil, ErrInvalidToken
                    }
                    return []byte(s.cfg.JWTRefreshSecret), nil
            })
            if err != nil || !parsed.Valid {
                    return nil
            }
            claims, ok := parsed.Claims.(jwt.MapClaims)
            if !ok {
                    return nil
            }
            jti, _ := claims["jti"].(string)
            if jti != "" && s.tokens != nil {
                    _ = s.tokens.Delete(ctx, refreshKey(jti))
            }
            return nil
    }

    func (s *AuthService) ParseAccessToken(ctx context.Context, accessToken string) ([]byte, error) {
            if accessToken == "" {
                    return nil, ErrInvalidToken
            }
            parsed, err := jwt.Parse(accessToken, func(t *jwt.Token) (any, error) {
                    if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
                            return nil, ErrInvalidToken
                    }
                    return []byte(s.cfg.JWTAccessSecret), nil
            })
            if err != nil || !parsed.Valid {
                    return nil, ErrInvalidToken
            }
            claims, ok := parsed.Claims.(jwt.MapClaims)
            if !ok || claims["type"] != "access" {
                    return nil, ErrInvalidToken
            }
            sub, _ := claims["sub"].(string)
            if sub == "" {
                    return nil, ErrInvalidToken
            }
            return uuidv7.ParseString(sub)
    }

    func (s *AuthService) GetUserByID(ctx context.Context, id []byte) (*auth.User, error) {
            return s.repo.GetUserByID(ctx, id)
    }

    func (s *AuthService) GetRoleByID(ctx context.Context, id []byte) (*auth.Role, error) {
            if s.roleRepo == nil {
                    return nil, errors.New("role repo not configured")
            }
            return s.roleRepo.GetRoleByID(ctx, id)
    }

    func (s *AuthService) AccessTTL() time.Duration  { return s.cfg.JWTAccessTTL }
    func (s *AuthService) RefreshTTL() time.Duration { return s.cfg.JWTRefreshTTL }
    func (s *AuthService) CookieDomain() string      { return s.cfg.JWTCookieDomain }
    func (s *AuthService) CookieSecure() bool        { return s.cfg.JWTCookieSecure }
    func (s *AuthService) CookieSameSite() string    { return s.cfg.JWTCookieSameSite }
    func (s *AuthService) AccessCookieName() string  { return s.cfg.JWTAccessCookieName }
    func (s *AuthService) RefreshCookieName() string { return s.cfg.JWTRefreshCookieName }
    func (s *AuthService) SSOSuccessRedirectURL() string { return s.cfg.SSOSuccessRedirectURL }

    // VerifyEmail verifies a token and marks the user as verified.
    func (s *AuthService) VerifyEmail(ctx context.Context, token string) error {
            if token == "" {
                    return ErrInvalidToken
            }
            key := emailVerifyKey(token)
            data, err := s.tokens.Get(ctx, key)
            if err != nil || len(data) != 16 {
                    return ErrInvalidToken
            }
            _ = s.tokens.Delete(ctx, key)
            return s.repo.MarkEmailVerified(ctx, data)
    }

    // SSO Microsoft (Entra) login flow: exchange code -> upsert identity -> get user.
    func (s *AuthService) LoginWithMicrosoft(ctx context.Context, code string) (*auth.UserIdentity, error) {
            claims, err := s.sso.ExchangeCode(ctx, code)
            if err != nil {
                    return nil, err
            }

            // If identity already exists, return it.
            existing, err := s.repo.GetIdentityByProviderSubject(ctx, "microsoft", claims.Subject)
            if err != nil {
                    return nil, err
            }
            if existing != nil && len(existing.UserID) == 16 {
                    return existing, nil
            }

            // Ensure user exists for this identity
            roleID, err := s.GetRoleIDByName(ctx, "user")
            if err != nil {
                    return nil, err
            }

            firstName, lastName := splitName(claims.Name)
            user := &auth.User{
                    ID:             uuidv7.MustBytes(),
                    Email:          strings.ToLower(strings.TrimSpace(claims.Email)),
                    FirstName:      firstName,
                    LastName:       lastName,
                    RoleID:         roleID,
                    EmailVerifiedAt: ptrTime(time.Now()),
            }
            if err := s.repo.CreateUser(ctx, user); err != nil {
                    return nil, err
            }

            identity := &auth.UserIdentity{
                    ID:              uuidv7.MustBytes(),
                    UserID:          user.ID,
                    Provider:        "microsoft",
                    ProviderSubject: claims.Subject,
                    Email:           strings.ToLower(strings.TrimSpace(claims.Email)),
            }

            if err := s.repo.UpsertIdentity(ctx, identity); err != nil {
                    return nil, err
            }

            return identity, nil
    }

    // TOTP verify: decrypt secret and verify code (6 digits).
    func (s *AuthService) VerifyTOTP(ctx context.Context, userID []byte, code string) (bool, error) {
            record, err := s.repo.GetUserTOTP(ctx, userID)
            if err != nil || record == nil || !record.Enabled {
                    return false, nil
            }

            secret, err := s.protector.Decrypt(record.SecretEncrypted)
            if err != nil {
                    return false, err
            }

            ok := verifyTOTP(string(secret), code)
            if ok {
                    _ = s.repo.UpdateUserTOTPLastUsed(ctx, userID)
            }
            return ok, nil
    }

    func (s *AuthService) CreateTOTPChallenge(ctx context.Context, userID []byte) (string, error) {
            if s.tokens == nil {
                    return "", errors.New("token store not configured")
            }
            token, err := randomToken(32)
            if err != nil {
                    return "", err
            }
            if err := s.tokens.Set(ctx, totpChallengeKey(token), userID, s.cfg.TOTPChallengeTTL); err != nil {
                    return "", err
            }
            return token, nil
    }

    func (s *AuthService) ConsumeTOTPChallenge(ctx context.Context, token string) ([]byte, error) {
            if s.tokens == nil || token == "" {
                    return nil, ErrInvalidToken
            }
            data, err := s.tokens.Get(ctx, totpChallengeKey(token))
            if err != nil || data == nil {
                    return nil, ErrInvalidToken
            }
            _ = s.tokens.Delete(ctx, totpChallengeKey(token))
            return data, nil
    }

    // SetupTOTP creates or replaces the user's TOTP secret and returns the provisioning URI.
    func (s *AuthService) SetupTOTP(ctx context.Context, userID []byte, accountName string) (secret, otpAuthURL, issuer string, err error) {
            if s.protector == nil {
                    return "", "", "", errors.New("secret protector not configured")
            }

            issuer = s.cfg.TOTPIssuer
            key, err := totp.Generate(totp.GenerateOpts{
                    Issuer:      issuer,
                    AccountName: accountName,
            })
            if err != nil {
                    return "", "", "", err
            }

            secret = key.Secret()
            otpAuthURL = key.URL()

            enc, err := s.protector.Encrypt([]byte(secret))
            if err != nil {
                    return "", "", "", err
            }

            record := &auth.UserTOTP{
                    UserID:          userID,
                    SecretEncrypted: enc,
                    Enabled:         false,
            }
            if err := s.repo.UpsertUserTOTP(ctx, record); err != nil {
                    return "", "", "", err
            }

            return secret, otpAuthURL, issuer, nil
    }

    func (s *AuthService) DisableTOTP(ctx context.Context, userID []byte) error {
            return s.repo.DisableUserTOTP(ctx, userID)
    }

    func (s *AuthService) ConfirmTOTP(ctx context.Context, userID []byte, code string) (bool, error) {
            record, err := s.repo.GetUserTOTP(ctx, userID)
            if err != nil || record == nil {
                    return false, ErrInvalidToken
            }
            secret, err := s.protector.Decrypt(record.SecretEncrypted)
            if err != nil {
                    return false, err
            }
            ok := verifyTOTP(string(secret), code)
            if !ok {
                    return false, nil
            }
            record.Enabled = true
            if err := s.repo.UpsertUserTOTP(ctx, record); err != nil {
                    return false, err
            }
            return true, nil
    }

    func (s *AuthService) sendEmailVerification(ctx context.Context, user *auth.User) error {
            if s.tokens == nil || s.cfg.EmailVerifyURL == "" {
                    return nil
            }

            token, err := randomToken(32)
            if err != nil {
                    return err
            }

            if err := s.tokens.Set(ctx, emailVerifyKey(token), user.ID, s.cfg.EmailVerifyTTL); err != nil {
                    return err
            }

            link := strings.TrimRight(s.cfg.EmailVerifyURL, "/") + "?token=" + token
            subject := "Verify your email"
            body := "Click this link to verify your email: " + link
            if s.emailQueue != nil {
                    return s.emailQueue.Enqueue(ctx, EmailJob{
                            To:      user.Email,
                            Subject: subject,
                            Body:    body,
                    })
            }
            if s.mailer != nil {
                    return s.mailer.Send(ctx, user.Email, subject, body)
            }
            return nil
    }

    func (s *AuthService) hashPassword(password string) (hash []byte, salt []byte, err error) {
            salt = make([]byte, 16)
            if _, err = rand.Read(salt); err != nil {
                    return nil, nil, err
            }
            peppered := []byte(password + s.cfg.PasswordPepper)
            hash = argon2.IDKey(peppered, salt, s.cfg.HashTime, s.cfg.HashMemoryKB, s.cfg.HashThreads, s.cfg.HashKeyLen)
            return hash, salt, nil
    }

    func (s *AuthService) verifyPassword(password string, hash, salt []byte) bool {
            if len(hash) == 0 || len(salt) == 0 {
                    return false
            }
            peppered := []byte(password + s.cfg.PasswordPepper)
            candidate := argon2.IDKey(peppered, salt, s.cfg.HashTime, s.cfg.HashMemoryKB, s.cfg.HashThreads, s.cfg.HashKeyLen)
            return subtleConstantTimeCompare(hash, candidate)
    }

    func randomToken(n int) (string, error) {
            b := make([]byte, n)
            if _, err := rand.Read(b); err != nil {
                    return "", err
            }
            return base64.RawURLEncoding.EncodeToString(b), nil
    }

    func emailVerifyKey(token string) string {
            return "auth:email_verify:" + token
    }

    func ssoStateKey(state string) string {
            return "auth:sso_state:" + state
    }

    func refreshKey(jti string) string {
            return "auth:refresh:" + jti
    }

    func totpChallengeKey(token string) string {
            return "auth:totp_challenge:" + token
    }

    func bytesToString(b []byte) string {
            s, _ := uuidv7.BytesToString(b)
            return s
    }

    func splitName(full string) (string, string) {
            full = strings.TrimSpace(full)
            if full == "" {
                    return "User", ""
            }
            parts := strings.Fields(full)
            if len(parts) == 1 {
                    return parts[0], ""
            }
            return parts[0], strings.Join(parts[1:], " ")
    }

    func ptrTime(t time.Time) *time.Time { return &t }

    func subtleConstantTimeCompare(a, b []byte) bool {
            if len(a) != len(b) {
                    return false
            }
            var v byte
            for i := 0; i < len(a); i++ {
                    v |= a[i] ^ b[i]
            }
            return v == 0
    }

    func verifyTOTP(secret, code string) bool {
            code = strings.ReplaceAll(code, " ", "")
            ok, _ := totp.ValidateCustom(code, secret, time.Now().UTC(), totp.ValidateOpts{
                    Period:    30,
                    Skew:      1,
                    Digits:    otp.DigitsSix,
                    Algorithm: otp.AlgorithmSHA1,
            })
            return ok
    }

    package service

    import (
            "context"
            "encoding/json"
    )

    type EmailJob struct {
            To      string `json:"to"`
            Subject string `json:"subject"`
            Body    string `json:"body"`
    }

    type EmailQueue interface {
            Enqueue(ctx context.Context, job EmailJob) error
            Dequeue(ctx context.Context) (*EmailJob, error)
    }

    func EncodeEmailJob(job EmailJob) ([]byte, error) {
            return json.Marshal(job)
    }

    func DecodeEmailJob(b []byte) (*EmailJob, error) {
            var job EmailJob
            if err := json.Unmarshal(b, &job); err != nil {
                    return nil, err
            }
            return &job, nil
    }

    package service

    import (
            "context"
            "encoding/base64"
            "encoding/json"
            "errors"
            "fmt"
            "io"
            "net/http"
            "net/url"
            "strings"
            "time"

            "wucher/internal/config"
    )

    type MicrosoftSSOClient struct {
            cfg    config.MicrosoftSSOConfig
            client *http.Client
    }

    func NewMicrosoftSSO(cfg config.MicrosoftSSOConfig) *MicrosoftSSOClient {
            return &MicrosoftSSOClient{
                    cfg: cfg,
                    client: &http.Client{
                            Timeout: 10 * time.Second,
                    },
            }
    }

    func (m *MicrosoftSSOClient) AuthCodeURL(state string) string {
            q := url.Values{}
            q.Set("client_id", m.cfg.ClientID)
            q.Set("response_type", "code")
            q.Set("redirect_uri", m.cfg.RedirectURL)
            q.Set("response_mode", "query")
            q.Set("scope", strings.Join(m.cfg.Scopes, " "))
            q.Set("state", state)
            return fmt.Sprintf("%s%s/oauth2/v2.0/authorize?%s", m.cfg.Authority, m.cfg.TenantID, q.Encode())
    }

    func (m *MicrosoftSSOClient) ExchangeCode(ctx context.Context, code string) (MicrosoftClaims, error) {
            if code == "" {
                    return MicrosoftClaims{}, errors.New("missing code")
            }

            form := url.Values{}
            form.Set("client_id", m.cfg.ClientID)
            form.Set("client_secret", m.cfg.ClientSecret)
            form.Set("grant_type", "authorization_code")
            form.Set("code", code)
            form.Set("redirect_uri", m.cfg.RedirectURL)
            form.Set("scope", strings.Join(m.cfg.Scopes, " "))

            tokenURL := fmt.Sprintf("%s%s/oauth2/v2.0/token", m.cfg.Authority, m.cfg.TenantID)
            req, err := http.NewRequestWithContext(ctx, http.MethodPost, tokenURL, strings.NewReader(form.Encode()))
            if err != nil {
                    return MicrosoftClaims{}, err
            }
            req.Header.Set("Content-Type", "application/x-www-form-urlencoded")

            resp, err := m.client.Do(req)
            if err != nil {
                    return MicrosoftClaims{}, err
            }
            defer resp.Body.Close()

            body, _ := io.ReadAll(resp.Body)
            if resp.StatusCode < 200 || resp.StatusCode >= 300 {
                    return MicrosoftClaims{}, fmt.Errorf("token exchange failed: %s", string(body))
            }

            var token struct {
                    IDToken string `json:"id_token"`
            }
            if err := json.Unmarshal(body, &token); err != nil {
                    return MicrosoftClaims{}, err
            }
            if token.IDToken == "" {
                    return MicrosoftClaims{}, errors.New("missing id_token")
            }

            claims, err := parseJWTClaims(token.IDToken)
            if err != nil {
                    return MicrosoftClaims{}, err
            }

            sub, _ := claims["sub"].(string)
            email, _ := claims["email"].(string)
            name, _ := claims["name"].(string)
            if email == "" {
                    // Some tenants use preferred_username
                    email, _ = claims["preferred_username"].(string)
            }

            return MicrosoftClaims{
                    Subject: sub,
                    Email:   email,
                    Name:    name,
            }, nil
    }

    func parseJWTClaims(token string) (map[string]any, error) {
            parts := strings.Split(token, ".")
            if len(parts) < 2 {
                    return nil, errors.New("invalid jwt")
            }
            payload, err := decodeSegment(parts[1])
            if err != nil {
                    return nil, err
            }
            var claims map[string]any
            if err := json.Unmarshal(payload, &claims); err != nil {
                    return nil, err
            }
            return claims, nil
    }

    func decodeSegment(seg string) ([]byte, error) {
            seg = strings.ReplaceAll(seg, "-", "+")
            seg = strings.ReplaceAll(seg, "_", "/")
            switch len(seg) % 4 {
            case 2:
                    seg += "=="
            case 3:
                    seg += "="
            }
            return io.ReadAll(base64.NewDecoder(base64.StdEncoding, strings.NewReader(seg)))
    }

    package service

    import (
            "context"
            "errors"
            "strings"

            "wucher/internal/domain/auth"
    )

    type RoleService struct {
            repo auth.RoleRepository
    }

    func NewRoleService(repo auth.RoleRepository) *RoleService {
            return &RoleService{repo: repo}
    }

    func (s *RoleService) Create(ctx context.Context, role *auth.Role) error {
            if strings.TrimSpace(role.Name) == "" {
                    return errors.New("name is required")
            }
            return s.repo.CreateRole(ctx, role)
    }

    func (s *RoleService) Update(ctx context.Context, role *auth.Role) error {
            if strings.TrimSpace(role.Name) == "" {
                    return errors.New("name is required")
            }
            return s.repo.UpdateRole(ctx, role)
    }

    func (s *RoleService) Delete(ctx context.Context, id []byte) error {
            return s.repo.DeleteRole(ctx, id)
    }

    func (s *RoleService) GetByID(ctx context.Context, id []byte) (*auth.Role, error) {
            return s.repo.GetRoleByID(ctx, id)
    }

    func (s *RoleService) GetByName(ctx context.Context, name string) (*auth.Role, error) {
            return s.repo.GetRoleByName(ctx, name)
    }

    func (s *RoleService) List(ctx context.Context, filterName, sort string, limit, offset int) ([]auth.Role, int64, error) {
            return s.repo.ListRoles(ctx, filterName, normalizeSort(sort), limit, offset)
    }

    func normalizeSort(sort string) string {
            // Allow list: name, -name, created_at, -created_at, updated_at, -updated_at
            switch sort {
            case "name":
                    return "name ASC"
            case "-name":
                    return "name DESC"
            case "created_at":
                    return "created_at ASC"
            case "-created_at":
                    return "created_at DESC"
            case "updated_at":
                    return "updated_at ASC"
            case "-updated_at":
                    return "updated_at DESC"
            default:
                    return ""
            }
    }

    package service

    import (
            "crypto/aes"
            "crypto/cipher"
            "crypto/rand"
            "encoding/base64"
            "errors"
            "io"
    )

    type AESGCMProtector struct {
            key []byte
    }

    func NewAESGCMProtectorFromBase64(keyB64 string) (*AESGCMProtector, error) {
            if keyB64 == "" {
                    return nil, errors.New("empty key")
            }
            key, err := base64.StdEncoding.DecodeString(keyB64)
            if err != nil {
                    return nil, err
            }
            if len(key) != 32 {
                    return nil, errors.New("key must be 32 bytes (base64-encoded)")
            }
            return &AESGCMProtector{key: key}, nil
    }

    func (p *AESGCMProtector) Encrypt(plain []byte) ([]byte, error) {
            block, err := aes.NewCipher(p.key)
            if err != nil {
                    return nil, err
            }
            gcm, err := cipher.NewGCM(block)
            if err != nil {
                    return nil, err
            }
            nonce := make([]byte, gcm.NonceSize())
            if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
                    return nil, err
            }
            ciphertext := gcm.Seal(nonce, nonce, plain, nil)
            return ciphertext, nil
    }

    func (p *AESGCMProtector) Decrypt(ciphertext []byte) ([]byte, error) {
            block, err := aes.NewCipher(p.key)
            if err != nil {
                    return nil, err
            }
            gcm, err := cipher.NewGCM(block)
            if err != nil {
                    return nil, err
            }
            if len(ciphertext) < gcm.NonceSize() {
                    return nil, errors.New("ciphertext too short")
            }
            nonce := ciphertext[:gcm.NonceSize()]
            data := ciphertext[gcm.NonceSize():]
            return gcm.Open(nil, nonce, data, nil)
    }

    package service

    import (
            "context"
            "crypto/tls"
            "errors"
            "fmt"
            "net"
            "net/mail"
            "net/smtp"
            "strings"
            "time"

            "wucher/internal/config"
    )

    type SMTPSender struct {
            cfg config.SMTPConfig
    }

    func NewSMTPSender(cfg config.SMTPConfig) *SMTPSender {
            return &SMTPSender{cfg: cfg}
    }

    func (s *SMTPSender) Send(ctx context.Context, to, subject, body string) error {
            if s.cfg.Host == "" || s.cfg.From == "" {
                    return errors.New("smtp not configured")
            }
            if to == "" {
                    return errors.New("recipient is required")
            }

            addr := fmt.Sprintf("%s:%d", s.cfg.Host, s.cfg.Port)
            fromAddr, err := mail.ParseAddress(s.cfg.From)
            if err != nil {
                    return err
            }
            toAddr, err := mail.ParseAddress(to)
            if err != nil {
                    return err
            }

            msg := buildMessage(s.cfg.From, to, subject, body)

            var c *smtp.Client
            var conn net.Conn

            dialer := &net.Dialer{Timeout: 10 * time.Second}
            tlsCfg := &tls.Config{
                    ServerName:         s.cfg.Host,
                    InsecureSkipVerify: s.cfg.InsecureSkipVerify,
            }

            if s.cfg.Port == 465 {
                    conn, err = tls.DialWithDialer(dialer, "tcp", addr, tlsCfg)
                    if err != nil {
                            return err
                    }
                    c, err = smtp.NewClient(conn, s.cfg.Host)
                    if err != nil {
                            _ = conn.Close()
                            return err
                    }
            } else {
                    conn, err = dialer.DialContext(ctx, "tcp", addr)
                    if err != nil {
                            return err
                    }
                    c, err = smtp.NewClient(conn, s.cfg.Host)
                    if err != nil {
                            _ = conn.Close()
                            return err
                    }

                    if ok, _ := c.Extension("STARTTLS"); ok {
                            if err := c.StartTLS(tlsCfg); err != nil {
                                    _ = c.Close()
                                    return err
                            }
                    }
            }
            defer func() { _ = c.Close() }()

            if s.cfg.Username != "" {
                    auth := smtp.PlainAuth("", s.cfg.Username, s.cfg.Password, s.cfg.Host)
                    if err := c.Auth(auth); err != nil {
                            return err
                    }
            }

            if err := c.Mail(fromAddr.Address); err != nil {
                    return err
            }
            if err := c.Rcpt(toAddr.Address); err != nil {
                    return err
            }

            w, err := c.Data()
            if err != nil {
                    return err
            }
            if _, err := w.Write([]byte(msg)); err != nil {
                    _ = w.Close()
                    return err
            }
            if err := w.Close(); err != nil {
                    return err
            }

            return c.Quit()
    }

    func buildMessage(from, to, subject, body string) string {
            subject = strings.ReplaceAll(subject, "\n", "")
            subject = strings.ReplaceAll(subject, "\r", "")
            return strings.Join([]string{
                    fmt.Sprintf("From: %s", from),
                    fmt.Sprintf("To: %s", to),
                    fmt.Sprintf("Subject: %s", subject),
                    "MIME-Version: 1.0",
                    `Content-Type: text/plain; charset="UTF-8"`,
                    "",
                    body,
            }, "\r\n")
    }

    package service

    import (
            "context"

            "wucher/internal/domain/user"
    )

    type UserService struct {
            repo user.Repository
    }

    func NewUserService(repo user.Repository) *UserService {
            return &UserService{repo: repo}
    }

    func (s *UserService) Create(ctx context.Context, u *user.User) error {
            return s.repo.Create(ctx, u)
    }

    func (s *UserService) GetByID(ctx context.Context, id []byte) (*user.User, error) {
            return s.repo.GetByID(ctx, id)
    }

    func (s *UserService) GetByEmail(ctx context.Context, email string) (*user.User, error) {
            return s.repo.GetByEmail(ctx, email)
    }

    package handlers

    import (
            "strings"

            "github.com/gofiber/fiber/v2"

            "wucher/internal/pkg/uuidv7"
            "wucher/internal/service"
            "wucher/internal/transport/http/dto"
            "wucher/internal/transport/http/jsonapi"
            "wucher/internal/transport/http/response"
            "wucher/internal/transport/http/validators"
    )

    type AuthHandler struct {
            svc      *service.AuthService
            validate *validators.Validator
    }

    func NewAuthHandler(svc *service.AuthService) *AuthHandler {
            return &AuthHandler{
                    svc:      svc,
                    validate: validators.New(),
            }
    }

    // Register godoc
    // @Summary Register user with email & password
    // @Description Creates user (default role: admin) and sends verification link to email.
    // @Description Next: open verify link -> then login.
    // @Tags Auth
    // @Accept json
    // @Produce json
    // @Param request body dto.RegisterRequest true "JSON:API Register request"
    // @Success 201 {object} dto.UserResponse
    // @Failure 422 {object} dto.ErrorResponse
    // @Failure 400 {object} dto.ErrorResponse
    // @Router /api/v1/auth/register [post]
    func (h *AuthHandler) Register(c *fiber.Ctx) error {
            if h.svc.RegisterDisabled() {
                    return response.WriteErrors(c, fiber.StatusForbidden, []jsonapi.ErrorObject{{
                            Status: "403",
                            Title:  "Registration disabled",
                            Detail: "registration is disabled in this environment",
                    }})
            }

            var req dto.RegisterRequest
            if err := c.BodyParser(&req); err != nil {
                    return response.WriteErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
                            Status: "400",
                            Title:  "Invalid JSON",
                            Detail: err.Error(),
                    }})
            }

            if errs := h.validate.ValidateStruct(req); errs != nil {
                    return response.WriteErrors(c, fiber.StatusUnprocessableEntity, errs)
            }

            roleID, err := h.svc.GetRoleIDByName(c.Context(), h.svc.DefaultRoleName())
            if err != nil {
                    return response.WriteErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
                            Status: "400",
                            Title:  "Role not found",
                            Detail: "default role not found",
                    }})
            }

            user, err := h.svc.RegisterWithEmail(
                    c.Context(),
                    req.Data.Attributes.Email,
                    req.Data.Attributes.FirstName,
                    req.Data.Attributes.LastName,
                    strings.TrimSpace(req.Data.Attributes.MobilePhone),
                    req.Data.Attributes.Password,
                    roleID,
            )
            if err != nil {
                    return response.WriteErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
                            Status: "400",
                            Title:  "Registration failed",
                            Detail: err.Error(),
                    }})
            }

            tokens, err := h.svc.IssueTokens(c.Context(), user)
            if err != nil {
                    return response.WriteErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
                            Status: "400",
                            Title:  "Token issue failed",
                            Detail: err.Error(),
                    }})
            }
            setAuthCookies(c, h.svc, tokens)

            id, _ := uuidv7.BytesToString(user.ID)
            resp := jsonapi.Resource{
                    Type: "user",
                    ID:   id,
                    Attributes: map[string]any{
                            "email":       user.Email,
                            "first_name":  user.FirstName,
                            "last_name":   user.LastName,
                            "role_id":     func() string { s, _ := uuidv7.BytesToString(roleID); return s }(),
                            "mobile_phone": user.MobilePhone,
                    },
            }
            return response.Write(c, fiber.StatusCreated, resp)
    }

    // Login godoc
    // @Summary Login with email & password
    // @Description If TOTP enabled, returns 202 + challenge_token. Then call /auth/totp/verify.
    // @Tags Auth
    // @Accept json
    // @Produce json
    // @Param request body dto.LoginRequest true "JSON:API Login request"
    // @Success 200 {object} dto.UserResponse
    // @Success 202 {object} dto.LoginTOTPRequiredResponse
    // @Failure 401 {object} dto.ErrorResponse
    // @Failure 422 {object} dto.ErrorResponse
    // @Router /api/v1/auth/login [post]
    func (h *AuthHandler) Login(c *fiber.Ctx) error {
            var req dto.LoginRequest
            if err := c.BodyParser(&req); err != nil {
                    return response.WriteErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
                            Status: "400",
                            Title:  "Invalid JSON",
                            Detail: err.Error(),
                    }})
            }
            if errs := h.validate.ValidateStruct(req); errs != nil {
                    return response.WriteErrors(c, fiber.StatusUnprocessableEntity, errs)
            }

            user, err := h.svc.LoginWithEmailPassword(c.Context(), req.Data.Attributes.Email, req.Data.Attributes.Password)
            if err != nil {
                    return response.WriteErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{
                            Status: "401",
                            Title:  "Unauthorized",
                            Detail: err.Error(),
                    }})
            }

            if ok, _ := h.svc.IsTOTPEnabled(c.Context(), user.ID); ok {
                    token, err := h.svc.CreateTOTPChallenge(c.Context(), user.ID)
                    if err != nil {
                            return response.WriteErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
                                    Status: "400",
                                    Title:  "TOTP challenge failed",
                                    Detail: err.Error(),
                            }})
                    }
                    return response.Write(c, fiber.StatusAccepted, jsonapi.Resource{
                            Type: "auth_totp_required",
                            Attributes: map[string]any{
                                    "requires_totp":  true,
                                    "challenge_token": token,
                            },
                    })
            }

            tokens, err := h.svc.IssueTokens(c.Context(), user)
            if err != nil {
                    return response.WriteErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
                            Status: "400",
                            Title:  "Token issue failed",
                            Detail: err.Error(),
                    }})
            }
            setAuthCookies(c, h.svc, tokens)

            id, _ := uuidv7.BytesToString(user.ID)
            roleID, _ := uuidv7.BytesToString(user.RoleID)
            var emailVerifiedAt string
            if user.EmailVerifiedAt != nil {
                    emailVerifiedAt = user.EmailVerifiedAt.UTC().Format("2006-01-02T15:04:05Z07:00")
            }
            var roleAttrs any
            role, _ := h.svc.GetRoleByID(c.Context(), user.RoleID)
            if role != nil {
                    roleIDStr, _ := uuidv7.BytesToString(role.ID)
                    roleAttrs = map[string]any{
                            "id":          roleIDStr,
                            "name":        role.Name,
                            "description": role.Description,
                    }
            }

            resp := jsonapi.Resource{
                    Type: "user",
                    ID:   id,
                    Attributes: map[string]any{
                            "email":             user.Email,
                            "first_name":        user.FirstName,
                            "last_name":         user.LastName,
                            "mobile_phone":      user.MobilePhone,
                            "role_id":           roleID,
                            "email_verified_at": emailVerifiedAt,
                            "role":              roleAttrs,
                    },
            }
            return response.Write(c, fiber.StatusOK, resp)
    }

    // VerifyEmail godoc
    // @Summary Verify email
    // @Description Confirms email verification after register.
    // @Tags Auth
    // @Produce json
    // @Param token query string true "Verification token"
    // @Success 200 {object} dto.VerifyEmailResponse
    // @Failure 400 {object} dto.ErrorResponse
    // @Router /api/v1/auth/verify-email [get]
    func (h *AuthHandler) VerifyEmail(c *fiber.Ctx) error {
            token := c.Query("token")
            if token == "" {
                    return response.WriteErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
                            Status: "400",
                            Title:  "Invalid token",
                            Detail: "token is required",
                            Source: &jsonapi.ErrorSource{Pointer: "/query/token"},
                    }})
            }
            if err := h.svc.VerifyEmail(c.Context(), token); err != nil {
                    return response.WriteErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
                            Status: "400",
                            Title:  "Invalid token",
                            Detail: err.Error(),
                    }})
            }
            return response.Write(c, fiber.StatusOK, jsonapi.Resource{
                    Type: "auth_verify_email",
                    Attributes: map[string]any{
                            "verified": true,
                    },
            })
    }

    // MicrosoftLogin godoc
    // @Summary Microsoft Entra SSO login
    // @Description Returns the Microsoft authorization URL. Browser should redirect user to this URL.
    // @Tags Auth
    // @Produce json
    // @Success 200 {object} dto.MicrosoftLoginResponse
    // @Failure 400 {object} dto.ErrorResponse
    // @Router /api/v1/auth/microsoft/login [get]
    func (h *AuthHandler) MicrosoftLogin(c *fiber.Ctx) error {
            urlStr, err := h.svc.BeginMicrosoftLogin(c.Context())
            if err != nil {
                    return response.WriteErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
                            Status: "400",
                            Title:  "SSO not configured",
                            Detail: err.Error(),
                    }})
            }
            return response.Write(c, fiber.StatusOK, jsonapi.Resource{
                    Type: "auth_microsoft_url",
                    Attributes: map[string]any{
                            "url": urlStr,
                    },
            })
    }

    // MicrosoftCallback godoc
    // @Summary Microsoft Entra SSO callback
    // @Description Sets auth cookies and redirects to AUTH_SSO_SUCCESS_REDIRECT if configured.
    // @Tags Auth
    // @Produce json
    // @Param code query string true "Authorization code"
    // @Param state query string true "State"
    // @Success 200 {object} dto.MicrosoftCallbackResponse
    // @Failure 400 {object} dto.ErrorResponse
    // @Router /api/v1/auth/microsoft/callback [get]
    func (h *AuthHandler) MicrosoftCallback(c *fiber.Ctx) error {
            code := c.Query("code")
            state := c.Query("state")
            if code == "" || state == "" {
                    return response.WriteErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
                            Status: "400",
                            Title:  "Invalid callback",
                            Detail: "code and state are required",
                    }})
            }
            identity, err := h.svc.CompleteMicrosoftLogin(c.Context(), code, state)
            if err != nil {
                    return response.WriteErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
                            Status: "400",
                            Title:  "SSO login failed",
                            Detail: err.Error(),
                    }})
            }

            // Issue cookies and redirect to FE if configured
            user, err := h.svc.GetUserByID(c.Context(), identity.UserID)
            if err == nil && user != nil {
                    tokens, err := h.svc.IssueTokens(c.Context(), user)
                    if err == nil {
                            setAuthCookies(c, h.svc, tokens)
                    }
            }

            if redirect := h.svc.SSOSuccessRedirectURL(); redirect != "" {
                    return c.Redirect(redirect, fiber.StatusFound)
            }

            id, _ := uuidv7.BytesToString(identity.ID)
            return response.Write(c, fiber.StatusOK, jsonapi.Resource{
                    Type: "auth_identity",
                    ID:   id,
                    Attributes: map[string]any{
                            "provider":         identity.Provider,
                            "provider_subject": identity.ProviderSubject,
                            "email":            identity.Email,
                    },
            })
    }

    // Me godoc
    // @Summary Get current user
    // @Tags Auth
    // @Produce json
    // @Success 200 {object} dto.UserResponse
    // @Failure 401 {object} dto.ErrorResponse
    // @Router /api/v1/auth/me [get]
    func (h *AuthHandler) Me(c *fiber.Ctx) error {
            userID, ok := c.Locals("user_id").([]byte)
            if !ok || len(userID) == 0 {
                    return response.WriteErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{
                            Status: "401",
                            Title:  "Unauthorized",
                            Detail: "missing auth context",
                    }})
            }

            user, err := h.svc.GetUserByID(c.Context(), userID)
            if err != nil || user == nil {
                    return response.WriteErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{
                            Status: "401",
                            Title:  "Unauthorized",
                            Detail: "user not found",
                    }})
            }

            id, _ := uuidv7.BytesToString(user.ID)
            roleID, _ := uuidv7.BytesToString(user.RoleID)
            var emailVerifiedAt string
            if user.EmailVerifiedAt != nil {
                    emailVerifiedAt = user.EmailVerifiedAt.UTC().Format("2006-01-02T15:04:05Z07:00")
            }

            var roleAttrs any
            role, _ := h.svc.GetRoleByID(c.Context(), user.RoleID)
            if role != nil {
                    roleIDStr, _ := uuidv7.BytesToString(role.ID)
                    roleAttrs = map[string]any{
                            "id":          roleIDStr,
                            "name":        role.Name,
                            "description": role.Description,
                    }
            }

            return response.Write(c, fiber.StatusOK, jsonapi.Resource{
                    Type: "user",
                    ID:   id,
                    Attributes: map[string]any{
                            "email":             user.Email,
                            "first_name":        user.FirstName,
                            "last_name":         user.LastName,
                            "mobile_phone":      user.MobilePhone,
                            "role_id":           roleID,
                            "email_verified_at": emailVerifiedAt,
                            "role":              roleAttrs,
                    },
            })
    }

    // Refresh godoc
    // @Summary Refresh access token
    // @Tags Auth
    // @Produce json
    // @Success 200 {object} dto.TokenResponse
    // @Failure 401 {object} dto.ErrorResponse
    // @Router /api/v1/auth/refresh [post]
    func (h *AuthHandler) Refresh(c *fiber.Ctx) error {
            refreshToken := c.Cookies(h.svc.RefreshCookieName())
            tokens, err := h.svc.RefreshTokens(c.Context(), refreshToken)
            if err != nil {
                    return response.WriteErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{
                            Status: "401",
                            Title:  "Unauthorized",
                            Detail: err.Error(),
                    }})
            }
            setAuthCookies(c, h.svc, tokens)
            return response.Write(c, fiber.StatusOK, jsonapi.Resource{
                    Type: "auth_tokens",
                    Attributes: map[string]any{
                            "access_expires_in":  int64(h.svc.AccessTTL().Seconds()),
                            "refresh_expires_in": int64(h.svc.RefreshTTL().Seconds()),
                    },
            })
    }

    // Logout godoc
    // @Summary Logout (revoke refresh)
    // @Tags Auth
    // @Produce json
    // @Success 200 {object} dto.VerifyEmailResponse
    // @Router /api/v1/auth/logout [post]
    func (h *AuthHandler) Logout(c *fiber.Ctx) error {
            refreshToken := c.Cookies(h.svc.RefreshCookieName())
            _ = h.svc.RevokeRefreshToken(c.Context(), refreshToken)
            clearAuthCookies(c, h.svc)
            return response.Write(c, fiber.StatusOK, jsonapi.Resource{
                    Type: "auth_logout",
                    Attributes: map[string]any{
                            "logged_out": true,
                    },
            })
    }

    // TOTPSetup godoc
    // @Summary Setup TOTP
    // @Description Generates secret and otpauth URL. TOTP is NOT enabled until confirmed.
    // @Tags Auth
    // @Accept json
    // @Produce json
    // @Param request body dto.TOTPSetupRequest true "JSON:API TOTP setup request"
    // @Success 200 {object} dto.TOTPSetupResponse
    // @Failure 422 {object} dto.ErrorResponse
    // @Router /api/v1/auth/totp/setup [post]
    func (h *AuthHandler) TOTPSetup(c *fiber.Ctx) error {
            var req dto.TOTPSetupRequest
            if err := c.BodyParser(&req); err != nil {
                    return response.WriteErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
                            Status: "400",
                            Title:  "Invalid JSON",
                            Detail: err.Error(),
                    }})
            }
            if errs := h.validate.ValidateStruct(req); errs != nil {
                    return response.WriteErrors(c, fiber.StatusUnprocessableEntity, errs)
            }

            userID, err := uuidv7.ParseString(req.Data.Attributes.UserID)
            if err != nil {
                    return response.WriteErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
                            Status: "422",
                            Title:  "Validation error",
                            Detail: "user_id is invalid UUID",
                            Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/user_id"},
                    }})
            }

            secret, otpURL, issuer, err := h.svc.SetupTOTP(c.Context(), userID, req.Data.Attributes.Email)
            if err != nil {
                    return response.WriteErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
                            Status: "400",
                            Title:  "TOTP setup failed",
                            Detail: err.Error(),
                    }})
            }

            return response.Write(c, fiber.StatusOK, jsonapi.Resource{
                    Type: "auth_totp_setup",
                    Attributes: map[string]any{
                            "secret":      secret,
                            "otpauth_url": otpURL,
                            "issuer":      issuer,
                    },
            })
    }

    // TOTPConfirm godoc
    // @Summary Confirm TOTP setup
    // @Description Validates initial TOTP code and enables TOTP for future logins.
    // @Tags Auth
    // @Accept json
    // @Produce json
    // @Param request body dto.TOTPConfirmRequest true "JSON:API TOTP confirm request"
    // @Success 200 {object} dto.VerifyEmailResponse
    // @Failure 422 {object} dto.ErrorResponse
    // @Router /api/v1/auth/totp/confirm [post]
    func (h *AuthHandler) TOTPConfirm(c *fiber.Ctx) error {
            var req dto.TOTPConfirmRequest
            if err := c.BodyParser(&req); err != nil {
                    return response.WriteErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
                            Status: "400",
                            Title:  "Invalid JSON",
                            Detail: err.Error(),
                    }})
            }
            if errs := h.validate.ValidateStruct(req); errs != nil {
                    return response.WriteErrors(c, fiber.StatusUnprocessableEntity, errs)
            }

            userID, err := uuidv7.ParseString(req.Data.Attributes.UserID)
            if err != nil {
                    return response.WriteErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
                            Status: "422",
                            Title:  "Validation error",
                            Detail: "user_id is invalid UUID",
                            Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/user_id"},
                    }})
            }

            ok, err := h.svc.ConfirmTOTP(c.Context(), userID, strings.TrimSpace(req.Data.Attributes.Code))
            if err != nil || !ok {
                    return response.WriteErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{
                            Status: "401",
                            Title:  "Invalid TOTP",
                            Detail: "code is invalid or expired",
                    }})
            }

            return response.Write(c, fiber.StatusOK, jsonapi.Resource{
                    Type: "auth_totp_confirm",
                    Attributes: map[string]any{
                            "confirmed": true,
                    },
            })
    }

    // TOTPVerify godoc
    // @Summary Verify TOTP code
    // @Description Completes login when TOTP is enabled and returns auth cookies.
    // @Tags Auth
    // @Accept json
    // @Produce json
    // @Param request body dto.TOTPVerifyRequest true "JSON:API TOTP verify request"
    // @Success 200 {object} dto.TokenResponse
    // @Failure 422 {object} dto.ErrorResponse
    // @Router /api/v1/auth/totp/verify [post]
    func (h *AuthHandler) TOTPVerify(c *fiber.Ctx) error {
            var req dto.TOTPVerifyRequest
            if err := c.BodyParser(&req); err != nil {
                    return response.WriteErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
                            Status: "400",
                            Title:  "Invalid JSON",
                            Detail: err.Error(),
                    }})
            }
            if errs := h.validate.ValidateStruct(req); errs != nil {
                    return response.WriteErrors(c, fiber.StatusUnprocessableEntity, errs)
            }

            userID, err := h.svc.ConsumeTOTPChallenge(c.Context(), strings.TrimSpace(req.Data.Attributes.ChallengeToken))
            if err != nil {
                    return response.WriteErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{
                            Status: "401",
                            Title:  "Invalid challenge",
                            Detail: "challenge token is invalid or expired",
                    }})
            }

            ok, err := h.svc.VerifyTOTP(c.Context(), userID, strings.TrimSpace(req.Data.Attributes.Code))
            if err != nil || !ok {
                    return response.WriteErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{
                            Status: "401",
                            Title:  "Invalid TOTP",
                            Detail: "code is invalid or expired",
                    }})
            }

            user, err := h.svc.GetUserByID(c.Context(), userID)
            if err != nil || user == nil {
                    return response.WriteErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{
                            Status: "401",
                            Title:  "Unauthorized",
                            Detail: "user not found",
                    }})
            }

            tokens, err := h.svc.IssueTokens(c.Context(), user)
            if err != nil {
                    return response.WriteErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
                            Status: "400",
                            Title:  "Token issue failed",
                            Detail: err.Error(),
                    }})
            }
            setAuthCookies(c, h.svc, tokens)

            return response.Write(c, fiber.StatusOK, jsonapi.Resource{
                    Type: "auth_tokens",
                    Attributes: map[string]any{
                            "access_expires_in":  int64(h.svc.AccessTTL().Seconds()),
                            "refresh_expires_in": int64(h.svc.RefreshTTL().Seconds()),
                    },
            })
    }

    // TOTPDisable godoc
    // @Summary Disable TOTP
    // @Tags Auth
    // @Accept json
    // @Produce json
    // @Param request body dto.TOTPDisableRequest true "JSON:API TOTP disable request"
    // @Success 200 {object} dto.VerifyEmailResponse
    // @Failure 422 {object} dto.ErrorResponse
    // @Router /api/v1/auth/totp/disable [post]
    func (h *AuthHandler) TOTPDisable(c *fiber.Ctx) error {
            var req dto.TOTPDisableRequest
            if err := c.BodyParser(&req); err != nil {
                    return response.WriteErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
                            Status: "400",
                            Title:  "Invalid JSON",
                            Detail: err.Error(),
                    }})
            }
            if errs := h.validate.ValidateStruct(req); errs != nil {
                    return response.WriteErrors(c, fiber.StatusUnprocessableEntity, errs)
            }

            userID, err := uuidv7.ParseString(req.Data.Attributes.UserID)
            if err != nil {
                    return response.WriteErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
                            Status: "422",
                            Title:  "Validation error",
                            Detail: "user_id is invalid UUID",
                            Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/user_id"},
                    }})
            }

            if err := h.svc.DisableTOTP(c.Context(), userID); err != nil {
                    return response.WriteErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
                            Status: "400",
                            Title:  "Disable TOTP failed",
                            Detail: err.Error(),
                    }})
            }

            return response.Write(c, fiber.StatusOK, jsonapi.Resource{
                    Type: "auth_totp_disable",
                    Attributes: map[string]any{
                            "disabled": true,
                    },
            })
    }

    func setAuthCookies(c *fiber.Ctx, svc *service.AuthService, tokens service.TokenPair) {
            c.Cookie(&fiber.Cookie{
                    Name:     svc.AccessCookieName(),
                    Value:    tokens.AccessToken,
                    HTTPOnly: true,
                    Secure:   svc.CookieSecure(),
                    SameSite: parseSameSite(svc.CookieSameSite()),
                    Domain:   svc.CookieDomain(),
                    Path:     "/",
                    MaxAge:   int(svc.AccessTTL().Seconds()),
            })
            c.Cookie(&fiber.Cookie{
                    Name:     svc.RefreshCookieName(),
                    Value:    tokens.RefreshToken,
                    HTTPOnly: true,
                    Secure:   svc.CookieSecure(),
                    SameSite: parseSameSite(svc.CookieSameSite()),
                    Domain:   svc.CookieDomain(),
                    Path:     "/api/v1/auth/refresh",
                    MaxAge:   int(svc.RefreshTTL().Seconds()),
            })
    }

    func clearAuthCookies(c *fiber.Ctx, svc *service.AuthService) {
            c.Cookie(&fiber.Cookie{
                    Name:     svc.AccessCookieName(),
                    Value:    "",
                    HTTPOnly: true,
                    Secure:   svc.CookieSecure(),
                    SameSite: parseSameSite(svc.CookieSameSite()),
                    Domain:   svc.CookieDomain(),
                    Path:     "/",
                    MaxAge:   -1,
            })
            c.Cookie(&fiber.Cookie{
                    Name:     svc.RefreshCookieName(),
                    Value:    "",
                    HTTPOnly: true,
                    Secure:   svc.CookieSecure(),
                    SameSite: parseSameSite(svc.CookieSameSite()),
                    Domain:   svc.CookieDomain(),
                    Path:     "/api/v1/auth/refresh",
                    MaxAge:   -1,
            })
    }

    func parseSameSite(v string) string {
            switch strings.ToLower(v) {
            case "strict":
                    return "Strict"
            case "none":
                    return "None"
            default:
                    return "Lax"
            }
    }

    package handlers

    import (
            "github.com/gofiber/fiber/v2"
            "wucher/internal/transport/http/response"
            "wucher/internal/transport/http/jsonapi"
    )

    type HealthHandler struct{}

    func NewHealthHandler() *HealthHandler {
            return &HealthHandler{}
    }

    func (h *HealthHandler) Handle(c *fiber.Ctx) error {
            return response.Write(c, fiber.StatusOK, jsonapi.Resource{
                    Type: "health",
                    Attributes: map[string]any{
                            "status": "ok",
                    },
            })
    }

    package handlers

    import (
            "strconv"
            "strings"
            "time"

            "github.com/gofiber/fiber/v2"

            "wucher/internal/domain/auth"
            "wucher/internal/pkg/uuidv7"
            "wucher/internal/service"
            "wucher/internal/transport/http/dto"
            "wucher/internal/transport/http/jsonapi"
            "wucher/internal/transport/http/response"
            "wucher/internal/transport/http/validators"
    )

    type RoleHandler struct {
            svc      *service.RoleService
            validate *validators.Validator
    }

    func NewRoleHandler(svc *service.RoleService) *RoleHandler {
            return &RoleHandler{
                    svc:      svc,
                    validate: validators.New(),
            }
    }

    // CreateRole godoc
    // @Summary Create role
    // @Description Create a new role. Name must be unique.
    // @Tags Roles
    // @Accept json
    // @Produce json
    // @Param request body dto.RoleCreateRequest true "JSON:API Role create request"
    // @Success 201 {object} dto.RoleResponse
    // @Failure 422 {object} dto.ErrorResponse
    // @Failure 400 {object} dto.ErrorResponse
    // @Router /api/v1/roles [post]
    func (h *RoleHandler) Create(c *fiber.Ctx) error {
            var req dto.RoleCreateRequest
            if err := c.BodyParser(&req); err != nil {
                    return response.WriteErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
                            Status: "400",
                            Title:  "Invalid JSON",
                            Detail: err.Error(),
                    }})
            }
            if errs := h.validate.ValidateStruct(req); errs != nil {
                    return response.WriteErrors(c, fiber.StatusUnprocessableEntity, errs)
            }

            role := &auth.Role{
                    Name:        strings.TrimSpace(req.Data.Attributes.Name),
                    Description: strings.TrimSpace(req.Data.Attributes.Description),
            }

            if err := h.svc.Create(c.Context(), role); err != nil {
                    return response.WriteErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
                            Status: "400",
                            Title:  "Create failed",
                            Detail: err.Error(),
                    }})
            }

            return response.Write(c, fiber.StatusCreated, roleResource(role))
    }

    // UpdateRole godoc
    // @Summary Update role
    // @Description Update role by ID. Name must be unique.
    // @Tags Roles
    // @Accept json
    // @Produce json
    // @Param id path string true "Role ID (UUIDv7)"
    // @Param request body dto.RoleUpdateRequest true "JSON:API Role update request"
    // @Success 200 {object} dto.RoleResponse
    // @Failure 422 {object} dto.ErrorResponse
    // @Failure 404 {object} dto.ErrorResponse
    // @Router /api/v1/roles/{id} [put]
    func (h *RoleHandler) Update(c *fiber.Ctx) error {
            idStr := c.Params("id")
            roleID, err := uuidv7.ParseString(idStr)
            if err != nil {
                    return response.WriteErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
                            Status: "422",
                            Title:  "Validation error",
                            Detail: "id is invalid UUID",
                            Source: &jsonapi.ErrorSource{Pointer: "/path/id"},
                    }})
            }

            var req dto.RoleUpdateRequest
            if err := c.BodyParser(&req); err != nil {
                    return response.WriteErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
                            Status: "400",
                            Title:  "Invalid JSON",
                            Detail: err.Error(),
                    }})
            }
            if errs := h.validate.ValidateStruct(req); errs != nil {
                    return response.WriteErrors(c, fiber.StatusUnprocessableEntity, errs)
            }

            existing, err := h.svc.GetByID(c.Context(), roleID)
            if err != nil || existing == nil {
                    return response.WriteErrors(c, fiber.StatusNotFound, []jsonapi.ErrorObject{{
                            Status: "404",
                            Title:  "Not found",
                            Detail: "role not found",
                    }})
            }

            existing.Name = strings.TrimSpace(req.Data.Attributes.Name)
            existing.Description = strings.TrimSpace(req.Data.Attributes.Description)

            if err := h.svc.Update(c.Context(), existing); err != nil {
                    return response.WriteErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
                            Status: "400",
                            Title:  "Update failed",
                            Detail: err.Error(),
                    }})
            }

            return response.Write(c, fiber.StatusOK, roleResource(existing))
    }

    // DeleteRole godoc
    // @Summary Delete role
    // @Tags Roles
    // @Produce json
    // @Param id path string true "Role ID (UUIDv7)"
    // @Success 200 {object} dto.VerifyEmailResponse
    // @Failure 404 {object} dto.ErrorResponse
    // @Router /api/v1/roles/{id} [delete]
    func (h *RoleHandler) Delete(c *fiber.Ctx) error {
            idStr := c.Params("id")
            roleID, err := uuidv7.ParseString(idStr)
            if err != nil {
                    return response.WriteErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
                            Status: "422",
                            Title:  "Validation error",
                            Detail: "id is invalid UUID",
                            Source: &jsonapi.ErrorSource{Pointer: "/path/id"},
                    }})
            }

            if err := h.svc.Delete(c.Context(), roleID); err != nil {
                    return response.WriteErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
                            Status: "400",
                            Title:  "Delete failed",
                            Detail: err.Error(),
                    }})
            }

            return response.Write(c, fiber.StatusOK, jsonapi.Resource{
                    Type: "role_delete",
                    Attributes: map[string]any{
                            "deleted": true,
                    },
            })
    }

    // GetRoleByID godoc
    // @Summary Get role by ID
    // @Tags Roles
    // @Produce json
    // @Param id path string true "Role ID (UUIDv7)"
    // @Success 200 {object} dto.RoleResponse
    // @Failure 404 {object} dto.ErrorResponse
    // @Router /api/v1/roles/{id} [get]
    func (h *RoleHandler) GetByID(c *fiber.Ctx) error {
            idStr := c.Params("id")
            roleID, err := uuidv7.ParseString(idStr)
            if err != nil {
                    return response.WriteErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
                            Status: "422",
                            Title:  "Validation error",
                            Detail: "id is invalid UUID",
                            Source: &jsonapi.ErrorSource{Pointer: "/path/id"},
                    }})
            }

            role, err := h.svc.GetByID(c.Context(), roleID)
            if err != nil || role == nil {
                    return response.WriteErrors(c, fiber.StatusNotFound, []jsonapi.ErrorObject{{
                            Status: "404",
                            Title:  "Not found",
                            Detail: "role not found",
                    }})
            }

            return response.Write(c, fiber.StatusOK, roleResource(role))
    }

    // ListRoles godoc
    // @Summary List roles
    // @Description JSON:API list with pagination, filtering and sorting. Sort values: name, -name, created_at, -created_at, updated_at, -updated_at.
    // @Tags Roles
    // @Produce json
    // @Param filter[name] query string false "Filter by name (contains)"
    // @Param page[number] query int false "Page number (default 1)"
    // @Param page[size] query int false "Page size (default 20, max 100)"
    // @Param sort query string false "Sort order"
    // @Success 200 {object} dto.RoleListResponse
    // @Router /api/v1/roles [get]
    func (h *RoleHandler) List(c *fiber.Ctx) error {
            filterName := c.Query("filter[name]")
            pageNumber := parseIntDefault(c.Query("page[number]"), 1)
            pageSize := parseIntDefault(c.Query("page[size]"), 20)
            if pageSize > 100 {
                    pageSize = 100
            }
            if pageNumber < 1 {
                    pageNumber = 1
            }

            offset := (pageNumber - 1) * pageSize
            sort := c.Query("sort")

            roles, total, err := h.svc.List(c.Context(), filterName, sort, pageSize, offset)
            if err != nil {
                    return response.WriteErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
                            Status: "400",
                            Title:  "List failed",
                            Detail: err.Error(),
                    }})
            }

            data := make([]dto.RoleResource, 0, len(roles))
            for i := range roles {
                    data = append(data, roleResource(&roles[i]))
            }

            meta := map[string]any{
                    "page_number": pageNumber,
                    "page_size":   pageSize,
                    "total":       total,
            }

            return response.WriteWithMeta(c, fiber.StatusOK, data, meta)
    }

    // ListRolesDatatable godoc
    // @Summary List roles (datatable)
    // @Description DataTables-style params with JSON:API response. Use draw/start/length/search[value].
    // @Tags Roles
    // @Produce json
    // @Param draw query int false "Draw counter"
    // @Param start query int false "Offset"
    // @Param length query int false "Page size"
    // @Param search[value] query string false "Search term"
    // @Success 200 {object} dto.RoleDataTableResponse
    // @Router /api/v1/roles/datatable [get]
    func (h *RoleHandler) ListDatatable(c *fiber.Ctx) error {
            draw := parseIntDefault(c.Query("draw"), 1)
            start := parseIntDefault(c.Query("start"), 0)
            length := parseIntDefault(c.Query("length"), 20)
            if length > 100 {
                    length = 100
            }
            search := c.Query("search[value]")

            roles, total, err := h.svc.List(c.Context(), search, "", length, start)
            if err != nil {
                    return response.WriteErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
                            Status: "400",
                            Title:  "List failed",
                            Detail: err.Error(),
                    }})
            }

            data := make([]dto.RoleResource, 0, len(roles))
            for i := range roles {
                    data = append(data, roleResource(&roles[i]))
            }

            meta := map[string]any{
                    "draw":             draw,
                    "records_total":    total,
                    "records_filtered": total,
            }

            return response.WriteWithMeta(c, fiber.StatusOK, data, meta)
    }

    func roleResource(role *auth.Role) dto.RoleResource {
            id, _ := uuidv7.BytesToString(role.ID)
            return dto.RoleResource{
                    Type: "role",
                    ID:   id,
                    Attributes: dto.RoleAttributes{
                            Name:        role.Name,
                            Description: role.Description,
                            CreatedAt:   role.CreatedAt.UTC().Format(time.RFC3339),
                            UpdatedAt:   role.UpdatedAt.UTC().Format(time.RFC3339),
                    },
            }
    }

    func parseIntDefault(v string, def int) int {
            if v == "" {
                    return def
            }
            n, err := strconv.Atoi(v)
            if err != nil {
                    return def
            }
            return n
    }

    package handlers

    import (
            "github.com/gofiber/fiber/v2"
            "wucher/internal/domain/user"
            "wucher/internal/transport/http/response"
            "wucher/internal/transport/http/jsonapi"
    )

    type UserHandler struct {
            svc user.Service
    }

    func NewUserHandler(svc user.Service) *UserHandler {
            return &UserHandler{svc: svc}
    }

    func (h *UserHandler) List(c *fiber.Ctx) error {
            return response.WriteErrors(c, fiber.StatusNotImplemented, []jsonapi.ErrorObject{{
                    Status: "501",
                    Title:  "Not implemented",
            }})
    }

    func (h *UserHandler) Get(c *fiber.Ctx) error {
            return response.WriteErrors(c, fiber.StatusNotImplemented, []jsonapi.ErrorObject{{
                    Status: "501",
                    Title:  "Not implemented",
            }})
    }

    package jsonapi

    import "github.com/gofiber/fiber/v2"

    type Resource struct {
            Type       string         `json:"type"`
            ID         string         `json:"id,omitempty"`
            Attributes map[string]any `json:"attributes,omitempty"`
    }

    type Document struct {
            Data any `json:"data"`
            Meta any `json:"meta,omitempty"`
    }

    type ErrorSource struct {
            Pointer string `json:"pointer,omitempty"`
    }

    type ErrorObject struct {
            Status string       `json:"status"`
            Code   string       `json:"code,omitempty"`
            Title  string       `json:"title,omitempty"`
            Detail string       `json:"detail,omitempty"`
            Source *ErrorSource `json:"source,omitempty"`
    }

    type ErrorDocument struct {
            Errors []ErrorObject `json:"errors"`
    }

    func Write(c *fiber.Ctx, status int, data any) error {
            return c.Status(status).JSON(Document{Data: data})
    }

    func WriteWithMeta(c *fiber.Ctx, status int, data any, meta any) error {
            return c.Status(status).JSON(Document{Data: data, Meta: meta})
    }

    func WriteErrors(c *fiber.Ctx, status int, errs []ErrorObject) error {
            return c.Status(status).JSON(ErrorDocument{Errors: errs})
    }

    package middleware

    import (
            "github.com/gofiber/fiber/v2"

            "wucher/internal/service"
            "wucher/internal/transport/http/jsonapi"
            "wucher/internal/transport/http/response"
    )

    func AuthRequired(svc *service.AuthService) fiber.Handler {
            return func(c *fiber.Ctx) error {
                    if svc == nil {
                            return response.WriteErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{
                                    Status: "401",
                                    Title:  "Unauthorized",
                                    Detail: "auth service not configured",
                            }})
                    }

                    token := c.Cookies(svc.AccessCookieName())
                    userID, err := svc.ParseAccessToken(c.Context(), token)
                    if err != nil {
                            return response.WriteErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{
                                    Status: "401",
                                    Title:  "Unauthorized",
                                    Detail: "invalid or expired token",
                            }})
                    }

                    c.Locals("user_id", userID)
                    return c.Next()
            }
    }

    package validators

    import (
            "fmt"
            "reflect"
            "strings"

            "github.com/go-playground/validator/v10"

            "wucher/internal/transport/http/jsonapi"
    )

    type Validator struct {
            validate *validator.Validate
    }

    func New() *Validator {
            v := validator.New()
            v.RegisterTagNameFunc(func(fld reflect.StructField) string {
                    name := fld.Tag.Get("json")
                    if name == "" {
                            return fld.Name
                    }
                    if idx := strings.Index(name, ","); idx != -1 {
                            return name[:idx]
                    }
                    return name
            })
            return &Validator{validate: v}
    }

    func (v *Validator) ValidateStruct(s any) []jsonapi.ErrorObject {
            if err := v.validate.Struct(s); err != nil {
                    if ve, ok := err.(validator.ValidationErrors); ok {
                            return toJSONAPIErrors(ve)
                    }
                    return []jsonapi.ErrorObject{{
                            Status: "400",
                            Title:  "Invalid request",
                            Detail: err.Error(),
                    }}
            }
            return nil
    }

    func toJSONAPIErrors(ve validator.ValidationErrors) []jsonapi.ErrorObject {
            errs := make([]jsonapi.ErrorObject, 0, len(ve))
            for _, fe := range ve {
                    field := fe.Field()
                    jsonPath := "/data/attributes/" + strings.ReplaceAll(field, ".", "/")
                    errs = append(errs, jsonapi.ErrorObject{
                            Status: "422",
                            Title:  "Validation error",
                            Detail: fmt.Sprintf("%s is %s", field, fe.Tag()),
                            Source: &jsonapi.ErrorSource{Pointer: jsonPath},
                    })
            }
            return errs
    }