// eulaPublicHandlers.go implements the public EULA API handlers. // These endpoints handle EULA retrieval and user agreement recording. package queryapi import ( "errors" "fmt" "net/http" "time" "queryorchestration/internal/cognitoauth" "queryorchestration/internal/eula" "github.com/google/uuid" "github.com/labstack/echo/v4" "github.com/oapi-codegen/nullable" openapi_types "github.com/oapi-codegen/runtime/types" ) // GetCurrentEula returns the current active EULA version. // This endpoint is public and does not require authentication. // GET /eula // // Returns: // - 200 OK with EulaPublicResponse on success // - 404 Not Found if no EULA is currently active // - 500 Internal Server Error on database errors func (s *Controllers) GetCurrentEula(ctx echo.Context) error { version, err := s.svc.Eula.GetCurrentVersion(ctx.Request().Context()) if err != nil { if errors.Is(err, eula.ErrNoCurrentVersion) { return ctx.JSON(http.StatusNotFound, ErrorMessage{ Message: "No current EULA version configured", }) } s.cfg.GetAuthLogger().Error("GetCurrentEula: failed to get current EULA version", "error", err) return ctx.JSON(http.StatusInternalServerError, ErrorMessage{ Message: fmt.Sprintf("Failed to get current EULA version: %v", err), }) } // Convert to public response response := EulaPublicResponse{ Id: openapi_types.UUID(version.ID), Version: version.Version, Title: version.Title, Content: version.Content, } // Handle nullable effectiveDate if version.Effectivedate.Valid { response.EffectiveDate = nullable.NewNullableWithValue(version.Effectivedate.Time) } return ctx.JSON(http.StatusOK, response) } // GetEulaStatus returns the current user's agreement status for the current EULA. // Used by the frontend to determine if the user needs to be prompted to agree. // GET /eula/status // // Requires authentication via JWT. // // Returns: // - 200 OK with EulaStatusResponse on success // - 401 Unauthorized if not authenticated // - 404 Not Found if no EULA is currently active // - 500 Internal Server Error on database errors func (s *Controllers) GetEulaStatus(ctx echo.Context) error { // Get user subject from JWT userSubject, ok := cognitoauth.GetUserSubject(ctx) if !ok { return ctx.JSON(http.StatusUnauthorized, ErrorMessage{ Message: "Authentication required", }) } status, err := s.svc.Eula.GetUserEulaStatus(ctx.Request().Context(), userSubject) if err != nil { if errors.Is(err, eula.ErrNoCurrentVersion) { return ctx.JSON(http.StatusNotFound, ErrorMessage{ Message: "No current EULA version configured", }) } s.cfg.GetAuthLogger().Error("GetEulaStatus: failed to get EULA status", "error", err, "user_subject", userSubject) return ctx.JSON(http.StatusInternalServerError, ErrorMessage{ Message: fmt.Sprintf("Failed to get EULA status: %v", err), }) } // Convert to API response response := EulaStatusResponse{ HasAgreed: status.HasAgreed, CurrentVersion: status.CurrentVersion, CurrentVersionId: openapi_types.UUID(status.CurrentVersionID), } if status.AgreedAt != nil { response.AgreedAt = nullable.NewNullableWithValue(*status.AgreedAt) } if status.AgreedVersion != nil { response.AgreedVersion = nullable.NewNullableWithValue(*status.AgreedVersion) } return ctx.JSON(http.StatusOK, response) } // AgreeToEula records the current user's agreement to the current EULA version. // This operation is idempotent - calling it multiple times will not create duplicate records. // POST /eula/agree // // Requires authentication via JWT. // IP address is automatically captured from the request. // // Returns: // - 201 Created with EulaAgreementResponse for new agreements // - 200 OK with EulaAgreementResponse if user already agreed (idempotent) // - 400 Bad Request if no current EULA is configured // - 401 Unauthorized if not authenticated // - 500 Internal Server Error on database errors func (s *Controllers) AgreeToEula(ctx echo.Context) error { // Get user info from JWT userSubject, subjectOK := cognitoauth.GetUserSubject(ctx) userInfo, infoOK := cognitoauth.GetUserInfo(ctx) if !subjectOK || !infoOK { s.cfg.GetAuthLogger().Error("AgreeToEula: authentication info missing from context", "subject_ok", subjectOK, "info_ok", infoOK) return ctx.JSON(http.StatusUnauthorized, ErrorMessage{ Message: "Authentication required", }) } // Resolve the user identifier for the agreement record. // Cognito access tokens do not include the email claim by default (only ID tokens do). // Fall back to username, then to subject ID (always present in any Cognito token). userIdentifier := userInfo.Email if userIdentifier == "" { userIdentifier = userInfo.Username s.cfg.GetAuthLogger().Info("AgreeToEula: email missing from token, using username fallback", "user_subject", userSubject, "username", userInfo.Username) } if userIdentifier == "" { userIdentifier = userSubject s.cfg.GetAuthLogger().Info("AgreeToEula: email and username missing from token, using subject ID fallback", "user_subject", userSubject) } // Get current EULA version currentVersion, err := s.svc.Eula.GetCurrentVersion(ctx.Request().Context()) if err != nil { if errors.Is(err, eula.ErrNoCurrentVersion) { return ctx.JSON(http.StatusBadRequest, ErrorMessage{ Message: "No current EULA version configured", }) } s.cfg.GetAuthLogger().Error("AgreeToEula: failed to get current EULA version", "error", err, "user_subject", userSubject) return ctx.JSON(http.StatusInternalServerError, ErrorMessage{ Message: fmt.Sprintf("Failed to get current EULA version: %v", err), }) } // Get client IP address ipAddress := ctx.RealIP() if ipAddress == "" { ipAddress = ctx.Request().RemoteAddr } // Record the agreement result, err := s.svc.Eula.RecordAgreement(ctx.Request().Context(), &eula.RecordAgreementInput{ CognitoSubjectID: userSubject, UserEmail: userIdentifier, EulaVersionID: currentVersion.ID, IPAddress: ipAddress, }) if err != nil { s.cfg.GetAuthLogger().Error("AgreeToEula: failed to record EULA agreement", "error", err, "user_subject", userSubject, "user_identifier", userIdentifier, "eula_version_id", currentVersion.ID) return ctx.JSON(http.StatusInternalServerError, ErrorMessage{ Message: fmt.Sprintf("Failed to record EULA agreement: %v", err), }) } // Build response var agreedAt time.Time if result.Agreement.Agreedat.Valid { agreedAt = result.Agreement.Agreedat.Time } response := EulaAgreementResponse{ Id: openapi_types.UUID(result.Agreement.ID), EulaVersionId: openapi_types.UUID(result.Agreement.Eulaversionid), EulaVersion: currentVersion.Version, AgreedAt: agreedAt, } // Return 200 if already agreed (idempotent), 201 for new agreement if result.AlreadyAgreed { return ctx.JSON(http.StatusOK, response) } return ctx.JSON(http.StatusCreated, response) } // convertVersionIDParam converts the path parameter to uuid.UUID. // Returns error if the ID format is invalid. func convertVersionIDParam(versionID EulaVersionID) (uuid.UUID, error) { return uuid.UUID(versionID), nil }