// eulaAdminHandlers.go implements the admin EULA API handlers. // These endpoints handle EULA version management, agreement listing, and compliance reporting. package queryapi import ( "context" "errors" "fmt" "net/http" "strings" "time" "queryorchestration/internal/database/repository" "queryorchestration/internal/eula" "queryorchestration/internal/serviceconfig/aws" "github.com/aws/aws-sdk-go-v2/service/cognitoidentityprovider" "github.com/google/uuid" "github.com/labstack/echo/v4" "github.com/oapi-codegen/nullable" openapi_types "github.com/oapi-codegen/runtime/types" ) // ListEulaVersions returns a paginated list of all EULA versions. // GET /admin/eula // // Requires AdminService permission. // // Parameters: // - page: Page number (1-based, default: 1) // - page_size: Items per page (1-100, default: 20) // // Returns: // - 200 OK with EulaVersionList on success // - 401 Unauthorized if not authenticated // - 403 Forbidden if not authorized // - 500 Internal Server Error on database errors func (s *Controllers) ListEulaVersions(ctx echo.Context, params ListEulaVersionsParams) error { // Apply defaults page := int32(1) pageSize := int32(20) if params.Page != nil { page = *params.Page } if params.PageSize != nil { pageSize = *params.PageSize } result, err := s.svc.Eula.ListVersions(ctx.Request().Context(), &eula.ListVersionsInput{ Page: page, PageSize: pageSize, }) if err != nil { s.cfg.GetAuthLogger().Error("ListEulaVersions: failed to list EULA versions", "error", err, "page", page, "page_size", pageSize) return ctx.JSON(http.StatusInternalServerError, ErrorMessage{ Message: fmt.Sprintf("Failed to list EULA versions: %v", err), }) } // Convert to API response versions := make([]EulaVersion, len(result.Versions)) for i, v := range result.Versions { versions[i] = convertVersionToAPI(v) } response := EulaVersionList{ Versions: versions, Total: int32(result.Total), // #nosec G115 -- Total is bounded by practical EULA version counts (< 1000) Page: page, PageSize: pageSize, HasMore: &result.HasMore, } return ctx.JSON(http.StatusOK, response) } // CreateEulaVersion creates a new EULA version. // POST /admin/eula // // Requires AdminService permission. // // Request body: EulaVersionCreate // // Returns: // - 201 Created with EulaVersion on success // - 400 Bad Request on validation errors // - 401 Unauthorized if not authenticated // - 403 Forbidden if not authorized // - 409 Conflict if version string already exists // - 500 Internal Server Error on database errors func (s *Controllers) CreateEulaVersion(ctx echo.Context) error { var req EulaVersionCreate if err := ctx.Bind(&req); err != nil { return ctx.JSON(http.StatusBadRequest, ErrorMessage{ Message: "Invalid request body", }) } // Get admin user for audit adminUser, err := getAdminUserForAudit(ctx) if err != nil { return ctx.JSON(http.StatusUnauthorized, ErrorMessage{ Message: "Could not identify admin user", }) } // Extract effectiveDate if specified (nullable field) var effectiveDate *time.Time if req.EffectiveDate.IsSpecified() && !req.EffectiveDate.IsNull() { val := req.EffectiveDate.MustGet() effectiveDate = &val } version, err := s.svc.Eula.CreateVersion(ctx.Request().Context(), &eula.CreateVersionInput{ Version: req.Version, Title: req.Title, Content: req.Content, EffectiveDate: effectiveDate, CreatedBy: adminUser.Email, }) if err != nil { // Check for duplicate version if strings.Contains(err.Error(), "duplicate") || strings.Contains(err.Error(), "unique") { return ctx.JSON(http.StatusConflict, ErrorMessage{ Message: "EULA version already exists", }) } s.cfg.GetAuthLogger().Error("CreateEulaVersion: failed to create EULA version", "error", err, "version", req.Version, "created_by", adminUser.Email) return ctx.JSON(http.StatusInternalServerError, ErrorMessage{ Message: fmt.Sprintf("Failed to create EULA version: %v", err), }) } return ctx.JSON(http.StatusCreated, convertVersionToAPI(version)) } // GetEulaVersion retrieves a specific EULA version by ID. // GET /admin/eula/{version_id} // // Requires AdminService permission. // // Returns: // - 200 OK with EulaVersion on success // - 401 Unauthorized if not authenticated // - 403 Forbidden if not authorized // - 404 Not Found if version doesn't exist // - 500 Internal Server Error on database errors func (s *Controllers) GetEulaVersion(ctx echo.Context, versionID EulaVersionID) error { id, err := convertVersionIDParam(versionID) if err != nil { return ctx.JSON(http.StatusBadRequest, ErrorMessage{ Message: "Invalid version ID format", }) } version, err := s.svc.Eula.GetVersionByID(ctx.Request().Context(), id) if err != nil { if errors.Is(err, eula.ErrVersionNotFound) { return ctx.JSON(http.StatusNotFound, ErrorMessage{ Message: "EULA version not found", }) } s.cfg.GetAuthLogger().Error("GetEulaVersion: failed to get EULA version", "error", err, "version_id", id) return ctx.JSON(http.StatusInternalServerError, ErrorMessage{ Message: fmt.Sprintf("Failed to get EULA version: %v", err), }) } return ctx.JSON(http.StatusOK, convertVersionToAPI(version)) } // UpdateEulaVersion updates the metadata of an EULA version. // Only title and effective date can be updated; content is immutable. // PATCH /admin/eula/{version_id} // // Requires AdminService permission. // // Request body: EulaVersionUpdate // // Returns: // - 200 OK with updated EulaVersion on success // - 400 Bad Request on validation errors // - 401 Unauthorized if not authenticated // - 403 Forbidden if not authorized // - 404 Not Found if version doesn't exist // - 500 Internal Server Error on database errors func (s *Controllers) UpdateEulaVersion(ctx echo.Context, versionID EulaVersionID) error { id, err := convertVersionIDParam(versionID) if err != nil { return ctx.JSON(http.StatusBadRequest, ErrorMessage{ Message: "Invalid version ID format", }) } var req EulaVersionUpdate if err := ctx.Bind(&req); err != nil { return ctx.JSON(http.StatusBadRequest, ErrorMessage{ Message: "Invalid request body", }) } input := &eula.UpdateVersionInput{} if req.Title != nil { input.Title = req.Title } if req.EffectiveDate != nil { input.EffectiveDate = req.EffectiveDate } version, err := s.svc.Eula.UpdateVersion(ctx.Request().Context(), id, input) if err != nil { if errors.Is(err, eula.ErrVersionNotFound) { return ctx.JSON(http.StatusNotFound, ErrorMessage{ Message: "EULA version not found", }) } s.cfg.GetAuthLogger().Error("UpdateEulaVersion: failed to update EULA version", "error", err, "version_id", id) return ctx.JSON(http.StatusInternalServerError, ErrorMessage{ Message: fmt.Sprintf("Failed to update EULA version: %v", err), }) } return ctx.JSON(http.StatusOK, convertVersionToAPI(version)) } // ActivateEulaVersion sets the specified version as the current active EULA. // POST /admin/eula/{version_id}/activate // // Requires AdminService permission. // Uses a transaction to atomically clear the previous current flag and set the new one. // // Returns: // - 200 OK with activated EulaVersion on success // - 401 Unauthorized if not authenticated // - 403 Forbidden if not authorized // - 404 Not Found if version doesn't exist // - 409 Conflict if another admin activated a different version concurrently // - 500 Internal Server Error on database errors func (s *Controllers) ActivateEulaVersion(ctx echo.Context, versionID EulaVersionID) error { id, err := convertVersionIDParam(versionID) if err != nil { return ctx.JSON(http.StatusBadRequest, ErrorMessage{ Message: "Invalid version ID format", }) } // Get admin user for audit adminUser, err := getAdminUserForAudit(ctx) if err != nil { return ctx.JSON(http.StatusUnauthorized, ErrorMessage{ Message: "Could not identify admin user", }) } version, err := s.svc.Eula.ActivateVersion(ctx.Request().Context(), id, adminUser.Email) if err != nil { if errors.Is(err, eula.ErrVersionNotFound) { return ctx.JSON(http.StatusNotFound, ErrorMessage{ Message: "EULA version not found", }) } if errors.Is(err, eula.ErrConcurrentActivation) { return ctx.JSON(http.StatusConflict, ErrorMessage{ Message: "Concurrent activation conflict - another version was activated. Please refresh and try again.", }) } s.cfg.GetAuthLogger().Error("ActivateEulaVersion: failed to activate EULA version", "error", err, "version_id", id, "activated_by", adminUser.Email) return ctx.JSON(http.StatusInternalServerError, ErrorMessage{ Message: fmt.Sprintf("Failed to activate EULA version: %v", err), }) } return ctx.JSON(http.StatusOK, convertVersionToAPI(version)) } // ListEulaAgreements returns a paginated list of EULA agreements. // GET /admin/eula/agreements // // Requires AdminService permission. // // Parameters: // - page: Page number (1-based, default: 1) // - page_size: Items per page (1-100, default: 20) // - version_id: Optional filter by EULA version // - user_id: Optional filter by Cognito subject ID // // Returns: // - 200 OK with EulaAgreementList on success // - 401 Unauthorized if not authenticated // - 403 Forbidden if not authorized // - 500 Internal Server Error on database errors func (s *Controllers) ListEulaAgreements(ctx echo.Context, params ListEulaAgreementsParams) error { // Apply defaults page := int32(1) pageSize := int32(20) if params.Page != nil { page = *params.Page } if params.PageSize != nil { pageSize = *params.PageSize } input := &eula.ListAgreementsInput{ Page: page, PageSize: pageSize, } // Convert optional UUID filter if params.VersionId != nil { versionID := uuid.UUID(*params.VersionId) input.VersionID = &versionID } if params.UserId != nil { input.UserID = params.UserId } result, err := s.svc.Eula.ListAgreements(ctx.Request().Context(), input) if err != nil { s.cfg.GetAuthLogger().Error("ListEulaAgreements: failed to list EULA agreements", "error", err, "page", page, "page_size", pageSize) return ctx.JSON(http.StatusInternalServerError, ErrorMessage{ Message: fmt.Sprintf("Failed to list EULA agreements: %v", err), }) } // Convert to API response agreements := make([]EulaAgreement, len(result.Agreements)) for i, a := range result.Agreements { agreements[i] = convertAgreementToAPI(a) } response := EulaAgreementList{ Agreements: agreements, Total: int32(result.Total), // #nosec G115 -- Total is bounded by pageSize pagination Page: page, PageSize: pageSize, HasMore: &result.HasMore, } return ctx.JSON(http.StatusOK, response) } // GetEulaCompliance returns a comprehensive compliance report. // GET /admin/eula/compliance // // Requires AdminService permission. // This endpoint queries Cognito for all users and compares with EULA agreements. // // Parameters: // - page: Page number (1-based, default: 1) // - page_size: Items per page (1-100, default: 50) // - version_id: Optional specific version to report on (defaults to current) // - agreed: Optional filter by agreement status // // Returns: // - 200 OK with EulaComplianceReport on success // - 401 Unauthorized if not authenticated // - 403 Forbidden if not authorized // - 404 Not Found if specified version doesn't exist or no current EULA // - 502 Bad Gateway on Cognito errors // - 500 Internal Server Error on database errors func (s *Controllers) GetEulaCompliance(ctx echo.Context, params GetEulaComplianceParams) error { // Initialize AWS config for Cognito awsCfg, err := aws.GetAWSConfig(context.Background()) if err != nil { s.cfg.GetAuthLogger().Error("GetEulaCompliance: failed to initialize AWS configuration", "error", err) return ctx.JSON(http.StatusInternalServerError, ErrorMessage{ Message: fmt.Sprintf("Failed to initialize AWS configuration: %v", err), }) } cognitoClient := cognitoidentityprovider.NewFromConfig(awsCfg) userPoolID := s.cfg.GetAuthUserPoolID() // Build service input from params input := &eula.ComplianceReportInput{ Page: 1, PageSize: 50, } if params.Page != nil { input.Page = *params.Page } if params.PageSize != nil { input.PageSize = *params.PageSize } if params.VersionId != nil { // Convert openapi_types.UUID to uuid.UUID versionID := uuid.UUID(*params.VersionId) input.VersionID = &versionID } if params.Agreed != nil { input.Agreed = params.Agreed } // Call service method result, err := s.svc.Eula.GetComplianceReport(ctx.Request().Context(), cognitoClient, userPoolID, input) if err != nil { if errors.Is(err, eula.ErrNoCurrentVersion) { return ctx.JSON(http.StatusNotFound, ErrorMessage{ Message: "No current EULA version configured", }) } if errors.Is(err, eula.ErrVersionNotFound) { return ctx.JSON(http.StatusNotFound, ErrorMessage{ Message: "EULA version not found", }) } // Check if it's a Cognito error if strings.Contains(err.Error(), "Cognito") || strings.Contains(err.Error(), "cognito") { s.cfg.GetAuthLogger().Error("GetEulaCompliance: Cognito error during compliance report", "error", err) return ctx.JSON(http.StatusBadGateway, ErrorMessage{ Message: "Failed to fetch users from Cognito: " + err.Error(), }) } s.cfg.GetAuthLogger().Error("GetEulaCompliance: failed to generate compliance report", "error", err) return ctx.JSON(http.StatusInternalServerError, ErrorMessage{ Message: fmt.Sprintf("Failed to generate compliance report: %v", err), }) } // Convert result to API response response := convertComplianceReportToAPI(result) return ctx.JSON(http.StatusOK, response) } // convertComplianceReportToAPI converts the service result to the API response type. func convertComplianceReportToAPI(result *eula.ComplianceReportResult) EulaComplianceReport { // Convert version to summary format versionSummary := EulaVersionSummary{ Id: openapi_types.UUID(result.Version.ID), Version: result.Version.Version, Title: result.Version.Title, IsCurrent: result.Version.Iscurrent, } // Handle nullable effectiveDate if result.Version.Effectivedate.Valid { versionSummary.EffectiveDate = nullable.NewNullableWithValue(result.Version.Effectivedate.Time) } // Convert summary summary := EulaComplianceSummary{ TotalUsers: result.Summary.TotalUsers, AgreedCount: result.Summary.AgreedCount, NotAgreedCount: result.Summary.NotAgreedCount, CompliancePercentage: result.Summary.CompliancePercentage, } // Convert users users := make([]EulaComplianceUser, 0, len(result.Users)) for _, u := range result.Users { user := EulaComplianceUser{ CognitoSubjectId: u.CognitoSubjectID, CurrentEmail: openapi_types.Email(u.CurrentEmail), Agreed: u.Agreed, } if u.Email != nil { user.Email = nullable.NewNullableWithValue(openapi_types.Email(*u.Email)) } if u.AgreedAt != nil { user.AgreedAt = nullable.NewNullableWithValue(*u.AgreedAt) } if u.AgreedFromIP != nil { user.AgreedFromIp = nullable.NewNullableWithValue(*u.AgreedFromIP) } users = append(users, user) } // Build pagination pagination := EulaCompliancePagination{ Page: result.Page, PageSize: result.PageSize, TotalPages: result.TotalPages, TotalItems: result.TotalItems, } return EulaComplianceReport{ EulaVersion: versionSummary, Summary: summary, Users: users, Pagination: pagination, } } // convertVersionToAPI converts a repository Eulaversion to the API EulaVersion type. func convertVersionToAPI(v *repository.Eulaversion) EulaVersion { version := EulaVersion{ Id: openapi_types.UUID(v.ID), Version: v.Version, Title: v.Title, Content: v.Content, CreatedAt: v.Createdat.Time, CreatedBy: openapi_types.Email(v.Createdby), IsCurrent: v.Iscurrent, } // Handle nullable effectiveDate if v.Effectivedate.Valid { version.EffectiveDate = nullable.NewNullableWithValue(v.Effectivedate.Time) } if v.Activatedat.Valid { version.ActivatedAt = nullable.NewNullableWithValue(v.Activatedat.Time) } if v.Activatedby != nil { version.ActivatedBy = nullable.NewNullableWithValue(openapi_types.Email(*v.Activatedby)) } return version } // convertAgreementToAPI converts a repository ListEulaAgreementsRow to the API EulaAgreement type. func convertAgreementToAPI(a *repository.ListEulaAgreementsRow) EulaAgreement { var agreedAt time.Time if a.Agreedat.Valid { agreedAt = a.Agreedat.Time } return EulaAgreement{ Id: openapi_types.UUID(a.ID), CognitoSubjectId: a.Cognitosubjectid, UserEmail: openapi_types.Email(a.Useremail), EulaVersionId: openapi_types.UUID(a.Eulaversionid), EulaVersion: a.Eulaversionstring, AgreedAt: agreedAt, AgreedFromIp: a.Agreedfromip, } }