diff --git a/CLAUDE.MD b/CLAUDE.MD index 51abb4d8..b648c2ce 100644 --- a/CLAUDE.MD +++ b/CLAUDE.MD @@ -1,26 +1,6 @@ # CLAUDE.md - This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. -## General rules DO NOT VIOLATE - -NEVER code additional features that I did not explicitly ask for without asking permission. -NEVER create mock data or simplified components unless explicitly told to do so -NEVER replace existing complex components with simplified versions - always fix the actual problem or the feature that was requested. -Never just bypass tests that have regressed. Stop and find out why they are failing and fix them. -ALWAYS work with the existing codebase - do not create new simplified alternatives -ALWAYS find and fix the root cause of issues instead of creating workarounds -When debugging issues, focus on fixing the existing implementation, not replacing it -When something doesn't work, debug and fix it - don't start over with a simple version -Don’t build things I am not specifically asking for unless its required to build the thing I am asking for. -When asked to make unit tests avoid mocks and use the actual thing that is being tested when reasonable. -For commands that read such as grep or cat there is no need to ask for permission. -Do not assume that we need backward compatibilty when implementing new features. If there is a decision to make that will affect this ask. Do not guess. - -## IDE related - -Always use ide diagnostics to validate code changes when running with ide integration (goland, vscode) - ## linting all edits after every change Always run `task lint` once you think you are done with edits to verify your changes have not caused other issues. diff --git a/api/queryAPI/adminHandlers.go b/api/queryAPI/adminHandlers.go index 3899271d..3c37b862 100644 --- a/api/queryAPI/adminHandlers.go +++ b/api/queryAPI/adminHandlers.go @@ -321,7 +321,12 @@ func (ctrl *Controllers) GetAdminUser(ctx echo.Context, email UserEmail) error { // Get user from Cognito cognitoUser, err := usermanagement.GetCognitoUser(context.Background(), cognitoClient, cognitoConfig.UserPoolID, string(email)) if err != nil { - ctrl.cfg.GetAuthLogger().Error("Failed to get user from Cognito", "error", err, "email", email) + errInfo := usermanagement.ClassifyCognitoError(err, string(email)) + ctrl.cfg.GetAuthLogger().Error("Failed to get user from Cognito", + "error", err, + "email", email, + "error_type", errInfo.ErrorType, + "http_status", errInfo.HTTPStatus) usermanagement.LogUserAdminAction( ctrl.cfg.GetAuthLogger(), usermanagement.ActionUpdate, @@ -330,12 +335,15 @@ func (ctrl *Controllers) GetAdminUser(ctx echo.Context, email UserEmail) error { string(email), "", usermanagement.ResultFailure, - fmt.Sprintf("User not found in Cognito: %v", err), + fmt.Sprintf("Cognito error (%s): %v", errInfo.ErrorType, err), ctx.Response().Header().Get(echo.HeaderXRequestID), ) - return ctx.JSON(http.StatusNotFound, ErrorMessage{ - Message: fmt.Sprintf("User not found: %s", email), + errorType := string(errInfo.ErrorType) + return ctx.JSON(errInfo.HTTPStatus, ErrorMessage{ + Message: errInfo.Message, + ErrorType: &errorType, + Details: &errInfo.Details, }) } @@ -392,11 +400,14 @@ func (ctrl *Controllers) CheckAdminUserExists(ctx echo.Context, email UserEmail) Region: ctrl.cfg.GetAuthRegion(), } - // Check if user exists in Cognito - exists := usermanagement.UserExistsInCognito(context.Background(), cognitoClient, cognitoConfig.UserPoolID, string(email)) - - if !exists { - return ctx.NoContent(http.StatusNotFound) + // Check if user exists in Cognito (use GetCognitoUser to get detailed error info) + _, err = usermanagement.GetCognitoUser(context.Background(), cognitoClient, cognitoConfig.UserPoolID, string(email)) + if err != nil { + errInfo := usermanagement.ClassifyCognitoError(err, string(email)) + // For HEAD requests, we can only return status code and headers + ctx.Response().Header().Set("X-Error-Type", string(errInfo.ErrorType)) + ctx.Response().Header().Set("X-Error-Message", errInfo.Message) + return ctx.NoContent(errInfo.HTTPStatus) } return ctx.NoContent(http.StatusOK) @@ -674,7 +685,12 @@ func (ctrl *Controllers) UpdateAdminUser(ctx echo.Context, email UserEmail) erro // Get user from Cognito to verify existence and get Subject ID cognitoUser, err := usermanagement.GetCognitoUser(context.Background(), cognitoClient, cognitoConfig.UserPoolID, string(email)) if err != nil { - ctrl.cfg.GetAuthLogger().Error("Failed to get user from Cognito", "error", err, "email", email) + errInfo := usermanagement.ClassifyCognitoError(err, string(email)) + ctrl.cfg.GetAuthLogger().Error("Failed to get user from Cognito", + "error", err, + "email", email, + "error_type", errInfo.ErrorType, + "http_status", errInfo.HTTPStatus) usermanagement.LogUserAdminAction( ctrl.cfg.GetAuthLogger(), usermanagement.ActionUpdate, @@ -683,12 +699,15 @@ func (ctrl *Controllers) UpdateAdminUser(ctx echo.Context, email UserEmail) erro string(email), "", usermanagement.ResultFailure, - fmt.Sprintf("User not found in Cognito: %v", err), + fmt.Sprintf("Cognito error (%s): %v", errInfo.ErrorType, err), ctx.Response().Header().Get(echo.HeaderXRequestID), ) - return ctx.JSON(http.StatusNotFound, ErrorMessage{ - Message: fmt.Sprintf("User not found: %s", email), + errorType := string(errInfo.ErrorType) + return ctx.JSON(errInfo.HTTPStatus, ErrorMessage{ + Message: errInfo.Message, + ErrorType: &errorType, + Details: &errInfo.Details, }) } @@ -773,7 +792,12 @@ func (ctrl *Controllers) DeleteAdminUser(ctx echo.Context, email UserEmail, para // Get user from Cognito to verify existence and get Subject ID cognitoUser, err := usermanagement.GetCognitoUser(context.Background(), cognitoClient, cognitoConfig.UserPoolID, string(email)) if err != nil { - ctrl.cfg.GetAuthLogger().Error("Failed to get user from Cognito", "error", err, "email", email) + errInfo := usermanagement.ClassifyCognitoError(err, string(email)) + ctrl.cfg.GetAuthLogger().Error("Failed to get user from Cognito", + "error", err, + "email", email, + "error_type", errInfo.ErrorType, + "http_status", errInfo.HTTPStatus) usermanagement.LogUserAdminAction( ctrl.cfg.GetAuthLogger(), usermanagement.ActionDelete, @@ -782,12 +806,15 @@ func (ctrl *Controllers) DeleteAdminUser(ctx echo.Context, email UserEmail, para string(email), "", usermanagement.ResultFailure, - fmt.Sprintf("User not found in Cognito: %v", err), + fmt.Sprintf("Cognito error (%s): %v", errInfo.ErrorType, err), ctx.Response().Header().Get(echo.HeaderXRequestID), ) - return ctx.JSON(http.StatusNotFound, ErrorMessage{ - Message: fmt.Sprintf("User not found: %s", email), + errorType := string(errInfo.ErrorType) + return ctx.JSON(errInfo.HTTPStatus, ErrorMessage{ + Message: errInfo.Message, + ErrorType: &errorType, + Details: &errInfo.Details, }) } @@ -899,7 +926,12 @@ func (ctrl *Controllers) DisableAdminUser(ctx echo.Context, email UserEmail) err // Get user from Cognito to verify existence and get Subject ID cognitoUser, err := usermanagement.GetCognitoUser(context.Background(), cognitoClient, cognitoConfig.UserPoolID, string(email)) if err != nil { - ctrl.cfg.GetAuthLogger().Error("Failed to get user from Cognito", "error", err, "email", email) + errInfo := usermanagement.ClassifyCognitoError(err, string(email)) + ctrl.cfg.GetAuthLogger().Error("Failed to get user from Cognito", + "error", err, + "email", email, + "error_type", errInfo.ErrorType, + "http_status", errInfo.HTTPStatus) usermanagement.LogUserAdminAction( ctrl.cfg.GetAuthLogger(), usermanagement.ActionDisable, @@ -908,12 +940,15 @@ func (ctrl *Controllers) DisableAdminUser(ctx echo.Context, email UserEmail) err string(email), "", usermanagement.ResultFailure, - fmt.Sprintf("User not found in Cognito: %v", err), + fmt.Sprintf("Cognito error (%s): %v", errInfo.ErrorType, err), ctx.Response().Header().Get(echo.HeaderXRequestID), ) - return ctx.JSON(http.StatusNotFound, ErrorMessage{ - Message: fmt.Sprintf("User not found: %s", email), + errorType := string(errInfo.ErrorType) + return ctx.JSON(errInfo.HTTPStatus, ErrorMessage{ + Message: errInfo.Message, + ErrorType: &errorType, + Details: &errInfo.Details, }) } @@ -995,7 +1030,12 @@ func (ctrl *Controllers) EnableAdminUser(ctx echo.Context, email UserEmail) erro // Get user from Cognito to verify existence and get Subject ID cognitoUser, err := usermanagement.GetCognitoUser(context.Background(), cognitoClient, cognitoConfig.UserPoolID, string(email)) if err != nil { - ctrl.cfg.GetAuthLogger().Error("Failed to get user from Cognito", "error", err, "email", email) + errInfo := usermanagement.ClassifyCognitoError(err, string(email)) + ctrl.cfg.GetAuthLogger().Error("Failed to get user from Cognito", + "error", err, + "email", email, + "error_type", errInfo.ErrorType, + "http_status", errInfo.HTTPStatus) usermanagement.LogUserAdminAction( ctrl.cfg.GetAuthLogger(), usermanagement.ActionEnable, @@ -1004,12 +1044,15 @@ func (ctrl *Controllers) EnableAdminUser(ctx echo.Context, email UserEmail) erro string(email), "", usermanagement.ResultFailure, - fmt.Sprintf("User not found in Cognito: %v", err), + fmt.Sprintf("Cognito error (%s): %v", errInfo.ErrorType, err), ctx.Response().Header().Get(echo.HeaderXRequestID), ) - return ctx.JSON(http.StatusNotFound, ErrorMessage{ - Message: fmt.Sprintf("User not found: %s", email), + errorType := string(errInfo.ErrorType) + return ctx.JSON(errInfo.HTTPStatus, ErrorMessage{ + Message: errInfo.Message, + ErrorType: &errorType, + Details: &errInfo.Details, }) } diff --git a/api/queryAPI/api.gen.go b/api/queryAPI/api.gen.go index 0a7297ee..8180514d 100644 --- a/api/queryAPI/api.gen.go +++ b/api/queryAPI/api.gen.go @@ -623,6 +623,12 @@ type DocumentSummary struct { // ErrorMessage Description of error type ErrorMessage struct { + // Details Additional error details for debugging (sanitized). + Details *string `json:"details,omitempty"` + + // ErrorType Machine-readable error classification for programmatic handling. + ErrorType *string `json:"error_type,omitempty"` + // Message Message describing the cause. Message string `json:"message"` } @@ -2669,272 +2675,275 @@ func RegisterHandlersWithBaseURL(router EchoRouter, si ServerInterface, baseURL // Base64 encoded, gzipped, json marshaled Swagger object var swaggerSpec = []string{ - "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", + "/wkAAP//LHP6SYqxAQA=", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/cmd/cognito_test/permit.setup/README.md b/cmd/cognito_test/permit.setup/README.md index 7a509701..b7c34c6a 100644 --- a/cmd/cognito_test/permit.setup/README.md +++ b/cmd/cognito_test/permit.setup/README.md @@ -32,9 +32,14 @@ they conflict with some of the names we use for our roles. ```bash export PERMIT_API_KEY="permit_key_omitted-here" go run setup_permit.go -list -go run setup_permit.go -config permit_policies.yaml -project default -env dev +go run setup_permit.go -config permit_policies.yaml -project default -env ``` +Note: When you do the list operation you will get the id of the target environment to put in the -env parameter. + +### Environments in permit +You must create a new enviroment in permit.io manually at this time. This tools just sets up the resources and policies for an existing enviromment. We will have to extend this tool later to create the environment if it does not already exist once we are creating a lot of enviroments. + ### Dry Run Mode To see what would be created without making actual changes: diff --git a/cmd/cognito_test/permit.setup/permit_policies.yaml b/cmd/cognito_test/permit.setup/permit_policies.yaml index 9a2d55a7..ed21219b 100644 --- a/cmd/cognito_test/permit.setup/permit_policies.yaml +++ b/cmd/cognito_test/permit.setup/permit_policies.yaml @@ -50,6 +50,19 @@ resources: - get - post + - name: field-extractions + description: Field extraction data for documents + actions: + - get + - post + + - name: folders + description: Folder management for document organization + actions: + - get + - post + - patch + - name: home description: Home page access actions: @@ -87,6 +100,13 @@ roles: export: - get - post + field-extractions: + - get + - post + folders: + - get + - post + - patch home: - get @@ -99,6 +119,12 @@ roles: - patch - delete - head + field-extractions: + - get + - post + folders: + - get + - post home: - get @@ -118,6 +144,10 @@ roles: - get export: - get + field-extractions: + - get + folders: + - get home: - get @@ -134,6 +164,13 @@ roles: export: - get - post + field-extractions: + - get + - post + folders: + - get + - post + - patch home: - get notes: | @@ -221,6 +258,18 @@ policy_mappings: methods: GET: export:get + field_extraction_endpoints: + - path: /field-extractions + methods: + GET: field-extractions:get + POST: field-extractions:post + - path: /field-extractions/version + methods: + GET: field-extractions:get + - path: /field-extractions/history + methods: + GET: field-extractions:get + auth_endpoints: - path: /home methods: @@ -253,6 +302,8 @@ dynamic_mapping: resource: document - url: /admin/users resource: admin + - url: /field-extractions + resource: field-extractions action_extraction: logic: HTTP method converted to lowercase diff --git a/go.mod b/go.mod index 153ce687..1295cee7 100644 --- a/go.mod +++ b/go.mod @@ -146,7 +146,7 @@ require ( github.com/aws/aws-sdk-go-v2/service/sso v1.25.1 // indirect github.com/aws/aws-sdk-go-v2/service/ssooidc v1.29.1 // indirect github.com/aws/aws-sdk-go-v2/service/sts v1.33.17 - github.com/aws/smithy-go v1.22.3 // indirect + github.com/aws/smithy-go v1.22.3 github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/containerd/log v0.1.0 // indirect github.com/containerd/platforms v0.2.1 // indirect diff --git a/internal/database/migrations/00000000000122_make_eula_effective_date_nullable.down.sql b/internal/database/migrations/00000000000122_make_eula_effective_date_nullable.down.sql new file mode 100644 index 00000000..34ed944e --- /dev/null +++ b/internal/database/migrations/00000000000122_make_eula_effective_date_nullable.down.sql @@ -0,0 +1,4 @@ +-- Revert: make effectiveDate required again +-- First set any NULL values to createdAt as a fallback +UPDATE eulaVersions SET effectiveDate = createdAt WHERE effectiveDate IS NULL; +ALTER TABLE eulaVersions ALTER COLUMN effectiveDate SET NOT NULL; diff --git a/internal/database/migrations/00000000000122_make_eula_effective_date_nullable.up.sql b/internal/database/migrations/00000000000122_make_eula_effective_date_nullable.up.sql new file mode 100644 index 00000000..ae2beabe --- /dev/null +++ b/internal/database/migrations/00000000000122_make_eula_effective_date_nullable.up.sql @@ -0,0 +1,3 @@ +-- Make effectiveDate nullable in eulaVersions table +-- This allows EULA versions to be created without specifying an effective date +ALTER TABLE eulaVersions ALTER COLUMN effectiveDate DROP NOT NULL; diff --git a/internal/server/api/listener.go b/internal/server/api/listener.go index 50ac36a1..24d9f705 100644 --- a/internal/server/api/listener.go +++ b/internal/server/api/listener.go @@ -9,6 +9,7 @@ import ( "net/http" "os" "strconv" + "strings" "testing" "time" @@ -162,6 +163,9 @@ func getDebugMiddleware(logger *slog.Logger) echo.MiddlewareFunc { } } +// getSlogCustomEchoMiddleware returns a middleware that logs HTTP requests. +// Noisy endpoints (swagger, metrics, health) are logged at DEBUG level to reduce log volume. +// All other endpoints are logged at INFO level. func getSlogCustomEchoMiddleware(logger *slog.Logger) echo.MiddlewareFunc { return func(next echo.HandlerFunc) echo.HandlerFunc { return func(c echo.Context) error { @@ -175,7 +179,16 @@ func getSlogCustomEchoMiddleware(logger *slog.Logger) echo.MiddlewareFunc { req := c.Request() res := c.Response() - logger.Info("HTTP request", + // Use Debug level for noisy endpoints (swagger, metrics, health) + path := req.RequestURI + logLevel := slog.LevelInfo + if strings.HasPrefix(path, "/swagger") || + strings.HasPrefix(path, "/metrics") || + path == "/health" { + logLevel = slog.LevelDebug + } + + logger.Log(req.Context(), logLevel, "HTTP request", "method", req.Method, "uri", req.RequestURI, "status", res.Status, diff --git a/internal/usermanagement/errors.go b/internal/usermanagement/errors.go new file mode 100644 index 00000000..a4eab02e --- /dev/null +++ b/internal/usermanagement/errors.go @@ -0,0 +1,312 @@ +// errors.go provides error classification for AWS Cognito errors. +// It maps AWS error types to appropriate HTTP status codes and sanitized error messages +// suitable for returning in API responses without leaking sensitive information. +package usermanagement + +import ( + "errors" + "fmt" + "net/http" + "strings" + + "github.com/aws/aws-sdk-go-v2/service/cognitoidentityprovider/types" + "github.com/aws/smithy-go" +) + +// CognitoErrorType represents a machine-readable error classification. +type CognitoErrorType string + +const ( + // ErrTypeUserNotFound indicates the user does not exist in Cognito. + ErrTypeUserNotFound CognitoErrorType = "UserNotFound" + + // ErrTypeAccessDenied indicates the caller does not have permission to perform the operation. + ErrTypeAccessDenied CognitoErrorType = "AccessDenied" + + // ErrTypeNotAuthorized indicates the caller is not authorized (e.g., invalid credentials). + ErrTypeNotAuthorized CognitoErrorType = "NotAuthorized" + + // ErrTypeInvalidParameter indicates an invalid parameter was provided. + ErrTypeInvalidParameter CognitoErrorType = "InvalidParameter" + + // ErrTypeRateLimited indicates too many requests were made. + ErrTypeRateLimited CognitoErrorType = "RateLimited" + + // ErrTypeServiceUnavailable indicates the Cognito service is temporarily unavailable. + ErrTypeServiceUnavailable CognitoErrorType = "ServiceUnavailable" + + // ErrTypeServiceError indicates a general error communicating with Cognito. + ErrTypeServiceError CognitoErrorType = "ServiceError" + + // ErrTypeUserExists indicates the user already exists in Cognito. + ErrTypeUserExists CognitoErrorType = "UserExists" +) + +// CognitoErrorInfo contains classified error information suitable for HTTP responses. +// The Message and Details fields are sanitized to avoid leaking sensitive information +// like ARNs, internal paths, or request IDs. +type CognitoErrorInfo struct { + // HTTPStatus is the appropriate HTTP status code for this error type. + HTTPStatus int + + // ErrorType is a machine-readable error classification. + ErrorType CognitoErrorType + + // Message is a user-friendly error message safe for HTTP responses. + Message string + + // Details provides additional error context (sanitized). + Details string +} + +// ClassifyCognitoError analyzes a Cognito error and returns appropriate HTTP response info. +// The returned CognitoErrorInfo contains sanitized messages suitable for API responses. +// +// Parameters: +// - err: The error returned from a Cognito operation +// - email: The user's email address for context in error messages +// +// Returns: +// - CognitoErrorInfo: Classified error information with appropriate HTTP status and messages +func ClassifyCognitoError(err error, email string) CognitoErrorInfo { + if err == nil { + return CognitoErrorInfo{ + HTTPStatus: http.StatusOK, + ErrorType: "", + Message: "", + Details: "", + } + } + + // Check for specific Cognito SDK error types + var userNotFoundErr *types.UserNotFoundException + if errors.As(err, &userNotFoundErr) { + return CognitoErrorInfo{ + HTTPStatus: http.StatusNotFound, + ErrorType: ErrTypeUserNotFound, + Message: fmt.Sprintf("User not found: %s", email), + Details: "The specified user does not exist in Cognito", + } + } + + var notAuthorizedErr *types.NotAuthorizedException + if errors.As(err, ¬AuthorizedErr) { + return CognitoErrorInfo{ + HTTPStatus: http.StatusUnauthorized, + ErrorType: ErrTypeNotAuthorized, + Message: "Not authorized to perform this operation", + Details: sanitizeErrorMessage(err.Error()), + } + } + + var invalidParamErr *types.InvalidParameterException + if errors.As(err, &invalidParamErr) { + return CognitoErrorInfo{ + HTTPStatus: http.StatusBadRequest, + ErrorType: ErrTypeInvalidParameter, + Message: "Invalid parameter in request", + Details: sanitizeErrorMessage(err.Error()), + } + } + + var tooManyRequestsErr *types.TooManyRequestsException + if errors.As(err, &tooManyRequestsErr) { + return CognitoErrorInfo{ + HTTPStatus: http.StatusTooManyRequests, + ErrorType: ErrTypeRateLimited, + Message: "Too many requests to Cognito", + Details: "Rate limit exceeded. Please retry after a short delay.", + } + } + + var internalErr *types.InternalErrorException + if errors.As(err, &internalErr) { + return CognitoErrorInfo{ + HTTPStatus: http.StatusServiceUnavailable, + ErrorType: ErrTypeServiceUnavailable, + Message: "Cognito service temporarily unavailable", + Details: "Internal service error. Please retry later.", + } + } + + var usernameExistsErr *types.UsernameExistsException + if errors.As(err, &usernameExistsErr) { + return CognitoErrorInfo{ + HTTPStatus: http.StatusConflict, + ErrorType: ErrTypeUserExists, + Message: fmt.Sprintf("User already exists: %s", email), + Details: "A user with this email already exists in Cognito", + } + } + + // Check for generic AWS API errors (including AccessDeniedException) + var apiErr smithy.APIError + if errors.As(err, &apiErr) { + return classifyAPIError(apiErr, email) + } + + // Default: treat as a generic service communication error + return CognitoErrorInfo{ + HTTPStatus: http.StatusBadGateway, + ErrorType: ErrTypeServiceError, + Message: "Failed to communicate with Cognito", + Details: sanitizeErrorMessage(err.Error()), + } +} + +// classifyAPIError handles generic AWS API errors that may not have specific SDK types. +// +// Parameters: +// - apiErr: The AWS API error +// - email: The user's email address for context in error messages +// +// Returns: +// - CognitoErrorInfo: Classified error information +func classifyAPIError(apiErr smithy.APIError, email string) CognitoErrorInfo { + code := apiErr.ErrorCode() + + switch code { + case "AccessDeniedException": + return CognitoErrorInfo{ + HTTPStatus: http.StatusForbidden, + ErrorType: ErrTypeAccessDenied, + Message: "Access denied to Cognito user pool", + Details: "The service does not have permission to perform this Cognito operation", + } + + case "UserNotFoundException": + return CognitoErrorInfo{ + HTTPStatus: http.StatusNotFound, + ErrorType: ErrTypeUserNotFound, + Message: fmt.Sprintf("User not found: %s", email), + Details: "The specified user does not exist in Cognito", + } + + case "NotAuthorizedException": + return CognitoErrorInfo{ + HTTPStatus: http.StatusUnauthorized, + ErrorType: ErrTypeNotAuthorized, + Message: "Not authorized to perform this operation", + Details: sanitizeErrorMessage(apiErr.ErrorMessage()), + } + + case "InvalidParameterException": + return CognitoErrorInfo{ + HTTPStatus: http.StatusBadRequest, + ErrorType: ErrTypeInvalidParameter, + Message: "Invalid parameter in request", + Details: sanitizeErrorMessage(apiErr.ErrorMessage()), + } + + case "TooManyRequestsException": + return CognitoErrorInfo{ + HTTPStatus: http.StatusTooManyRequests, + ErrorType: ErrTypeRateLimited, + Message: "Too many requests to Cognito", + Details: "Rate limit exceeded. Please retry after a short delay.", + } + + case "ServiceUnavailable", "InternalError", "InternalErrorException": + return CognitoErrorInfo{ + HTTPStatus: http.StatusServiceUnavailable, + ErrorType: ErrTypeServiceUnavailable, + Message: "Cognito service temporarily unavailable", + Details: "Internal service error. Please retry later.", + } + + case "UsernameExistsException": + return CognitoErrorInfo{ + HTTPStatus: http.StatusConflict, + ErrorType: ErrTypeUserExists, + Message: fmt.Sprintf("User already exists: %s", email), + Details: "A user with this email already exists in Cognito", + } + + default: + return CognitoErrorInfo{ + HTTPStatus: http.StatusBadGateway, + ErrorType: ErrTypeServiceError, + Message: "Failed to communicate with Cognito", + Details: sanitizeErrorMessage(apiErr.ErrorMessage()), + } + } +} + +// sanitizeErrorMessage removes sensitive information from error messages. +// It strips out ARNs, request IDs, and internal resource names. +// +// Parameters: +// - msg: The raw error message +// +// Returns: +// - string: The sanitized error message +func sanitizeErrorMessage(msg string) string { + msg = removeARNs(msg) + msg = removeRequestIDs(msg) + msg = normalizeWhitespace(msg) + return msg +} + +// removeARNs replaces all AWS ARNs with [REDACTED]. +func removeARNs(msg string) string { + for { + start := strings.Index(msg, "arn:aws:") + if start == -1 { + return msg + } + end := findEndOfToken(msg, start) + msg = msg[:start] + "[REDACTED]" + msg[end:] + } +} + +// removeRequestIDs removes request ID patterns and their values from the message. +func removeRequestIDs(msg string) string { + patterns := []string{"requestid:", "request-id:", "request id:"} + msgLower := strings.ToLower(msg) + + for _, pattern := range patterns { + for { + idx := strings.Index(msgLower, pattern) + if idx == -1 { + break + } + end := idx + len(pattern) + end = skipWhitespace(msg, end) + end = findEndOfToken(msg, end) + msg = msg[:idx] + msg[end:] + msgLower = strings.ToLower(msg) + } + } + return msg +} + +// findEndOfToken finds the end of a token (space, comma, or newline delimited). +func findEndOfToken(msg string, start int) int { + end := start + for end < len(msg) && !isTokenDelimiter(msg[end]) { + end++ + } + return end +} + +// isTokenDelimiter returns true if the character is a token delimiter. +func isTokenDelimiter(c byte) bool { + return c == ' ' || c == ',' || c == '\n' || c == '\r' +} + +// skipWhitespace advances past whitespace characters. +func skipWhitespace(msg string, pos int) int { + for pos < len(msg) && (msg[pos] == ' ' || msg[pos] == '\t') { + pos++ + } + return pos +} + +// normalizeWhitespace trims and collapses multiple spaces. +func normalizeWhitespace(msg string) string { + msg = strings.TrimSpace(msg) + for strings.Contains(msg, " ") { + msg = strings.ReplaceAll(msg, " ", " ") + } + return msg +} diff --git a/internal/usermanagement/errors_test.go b/internal/usermanagement/errors_test.go new file mode 100644 index 00000000..d2bb8219 --- /dev/null +++ b/internal/usermanagement/errors_test.go @@ -0,0 +1,240 @@ +package usermanagement + +import ( + "errors" + "net/http" + "testing" + + "github.com/aws/aws-sdk-go-v2/service/cognitoidentityprovider/types" + "github.com/aws/smithy-go" + "github.com/stretchr/testify/assert" +) + +// mockAPIError implements smithy.APIError for testing. +type mockAPIError struct { + code string + message string +} + +func (e *mockAPIError) Error() string { return e.message } +func (e *mockAPIError) ErrorCode() string { return e.code } +func (e *mockAPIError) ErrorMessage() string { return e.message } +func (e *mockAPIError) ErrorFault() smithy.ErrorFault { return smithy.FaultServer } + +func TestClassifyCognitoError_NilError(t *testing.T) { + result := ClassifyCognitoError(nil, "test@example.com") + + assert.Equal(t, http.StatusOK, result.HTTPStatus) + assert.Equal(t, CognitoErrorType(""), result.ErrorType) + assert.Empty(t, result.Message) +} + +func TestClassifyCognitoError_UserNotFoundException(t *testing.T) { + email := "test@example.com" + err := &types.UserNotFoundException{ + Message: stringPtr("User does not exist"), + } + + result := ClassifyCognitoError(err, email) + + assert.Equal(t, http.StatusNotFound, result.HTTPStatus) + assert.Equal(t, ErrTypeUserNotFound, result.ErrorType) + assert.Contains(t, result.Message, email) + assert.NotEmpty(t, result.Details) +} + +func TestClassifyCognitoError_NotAuthorizedException(t *testing.T) { + email := "test@example.com" + err := &types.NotAuthorizedException{ + Message: stringPtr("User is not authorized"), + } + + result := ClassifyCognitoError(err, email) + + assert.Equal(t, http.StatusUnauthorized, result.HTTPStatus) + assert.Equal(t, ErrTypeNotAuthorized, result.ErrorType) + assert.NotEmpty(t, result.Message) +} + +func TestClassifyCognitoError_InvalidParameterException(t *testing.T) { + email := "test@example.com" + err := &types.InvalidParameterException{ + Message: stringPtr("Invalid email format"), + } + + result := ClassifyCognitoError(err, email) + + assert.Equal(t, http.StatusBadRequest, result.HTTPStatus) + assert.Equal(t, ErrTypeInvalidParameter, result.ErrorType) + assert.NotEmpty(t, result.Message) +} + +func TestClassifyCognitoError_TooManyRequestsException(t *testing.T) { + email := "test@example.com" + err := &types.TooManyRequestsException{ + Message: stringPtr("Rate exceeded"), + } + + result := ClassifyCognitoError(err, email) + + assert.Equal(t, http.StatusTooManyRequests, result.HTTPStatus) + assert.Equal(t, ErrTypeRateLimited, result.ErrorType) + assert.NotEmpty(t, result.Message) +} + +func TestClassifyCognitoError_InternalErrorException(t *testing.T) { + email := "test@example.com" + err := &types.InternalErrorException{ + Message: stringPtr("Internal server error"), + } + + result := ClassifyCognitoError(err, email) + + assert.Equal(t, http.StatusServiceUnavailable, result.HTTPStatus) + assert.Equal(t, ErrTypeServiceUnavailable, result.ErrorType) + assert.NotEmpty(t, result.Message) +} + +func TestClassifyCognitoError_UsernameExistsException(t *testing.T) { + email := "test@example.com" + err := &types.UsernameExistsException{ + Message: stringPtr("User already exists"), + } + + result := ClassifyCognitoError(err, email) + + assert.Equal(t, http.StatusConflict, result.HTTPStatus) + assert.Equal(t, ErrTypeUserExists, result.ErrorType) + assert.Contains(t, result.Message, email) +} + +func TestClassifyCognitoError_AccessDeniedException_ViaAPIError(t *testing.T) { + email := "test@example.com" + err := &mockAPIError{ + code: "AccessDeniedException", + message: "User: arn:aws:sts::123456789:assumed-role/role is not authorized", + } + + result := ClassifyCognitoError(err, email) + + assert.Equal(t, http.StatusForbidden, result.HTTPStatus) + assert.Equal(t, ErrTypeAccessDenied, result.ErrorType) + assert.Contains(t, result.Message, "Access denied") +} + +func TestClassifyCognitoError_GenericError(t *testing.T) { + email := "test@example.com" + err := errors.New("some unknown error") + + result := ClassifyCognitoError(err, email) + + assert.Equal(t, http.StatusBadGateway, result.HTTPStatus) + assert.Equal(t, ErrTypeServiceError, result.ErrorType) + assert.Contains(t, result.Message, "Failed to communicate with Cognito") +} + +func TestClassifyCognitoError_UnknownAPIError(t *testing.T) { + email := "test@example.com" + err := &mockAPIError{ + code: "SomeUnknownException", + message: "Unknown error occurred", + } + + result := ClassifyCognitoError(err, email) + + assert.Equal(t, http.StatusBadGateway, result.HTTPStatus) + assert.Equal(t, ErrTypeServiceError, result.ErrorType) +} + +func TestSanitizeErrorMessage_RemovesARNs(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "removes single ARN", + input: "User: arn:aws:sts::123456789012:assumed-role/TestRole/session is not authorized", + expected: "User: [REDACTED] is not authorized", + }, + { + name: "removes multiple ARNs", + input: "arn:aws:iam::123:user/test and arn:aws:s3:::bucket", + expected: "[REDACTED] and [REDACTED]", + }, + { + name: "handles no ARN", + input: "Simple error message", + expected: "Simple error message", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := sanitizeErrorMessage(tt.input) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestSanitizeErrorMessage_RemovesRequestIDs(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "removes RequestID", + input: "Error occurred, RequestID: abc-123-def", + expected: "Error occurred,", + }, + { + name: "removes Request ID with space", + input: "Error occurred, Request ID: abc-123-def something", + expected: "Error occurred, something", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := sanitizeErrorMessage(tt.input) + assert.NotContains(t, result, "abc-123-def") + }) + } +} + +func TestClassifyAPIError_AllCases(t *testing.T) { + email := "user@test.com" + + tests := []struct { + name string + code string + expectedStatus int + expectedType CognitoErrorType + }{ + {"AccessDenied", "AccessDeniedException", http.StatusForbidden, ErrTypeAccessDenied}, + {"UserNotFound", "UserNotFoundException", http.StatusNotFound, ErrTypeUserNotFound}, + {"NotAuthorized", "NotAuthorizedException", http.StatusUnauthorized, ErrTypeNotAuthorized}, + {"InvalidParameter", "InvalidParameterException", http.StatusBadRequest, ErrTypeInvalidParameter}, + {"TooManyRequests", "TooManyRequestsException", http.StatusTooManyRequests, ErrTypeRateLimited}, + {"ServiceUnavailable", "ServiceUnavailable", http.StatusServiceUnavailable, ErrTypeServiceUnavailable}, + {"InternalError", "InternalError", http.StatusServiceUnavailable, ErrTypeServiceUnavailable}, + {"UsernameExists", "UsernameExistsException", http.StatusConflict, ErrTypeUserExists}, + {"Unknown", "SomeRandomException", http.StatusBadGateway, ErrTypeServiceError}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + apiErr := &mockAPIError{code: tt.code, message: "test message"} + result := classifyAPIError(apiErr, email) + + assert.Equal(t, tt.expectedStatus, result.HTTPStatus) + assert.Equal(t, tt.expectedType, result.ErrorType) + }) + } +} + +// stringPtr is a helper to create a pointer to a string. +func stringPtr(s string) *string { + return &s +} diff --git a/pkg/queryAPI/api.gen.go b/pkg/queryAPI/api.gen.go index 8da01903..49a281d4 100644 --- a/pkg/queryAPI/api.gen.go +++ b/pkg/queryAPI/api.gen.go @@ -621,6 +621,12 @@ type DocumentSummary struct { // ErrorMessage Description of error type ErrorMessage struct { + // Details Additional error details for debugging (sanitized). + Details *string `json:"details,omitempty"` + + // ErrorType Machine-readable error classification for programmatic handling. + ErrorType *string `json:"error_type,omitempty"` + // Message Message describing the cause. Message string `json:"message"` } diff --git a/serviceAPIs/queryAPI.yaml b/serviceAPIs/queryAPI.yaml index b8569864..f1caa235 100644 --- a/serviceAPIs/queryAPI.yaml +++ b/serviceAPIs/queryAPI.yaml @@ -3039,10 +3039,22 @@ components: description: Message describing the cause. maxLength: 256 example: "you must give me good information" + error_type: + type: string + description: Machine-readable error classification for programmatic handling. + maxLength: 64 + example: "UserNotFound" + details: + type: string + description: Additional error details for debugging (sanitized). + maxLength: 512 + example: "The specified user does not exist in Cognito" required: - message example: message: you must include a message + error_type: InvalidParameter + details: The email format is invalid # Identity API schemas IdentityRole: diff --git a/test/demo.data.loader/README.md b/test/demo.data.loader/README.md new file mode 100644 index 00000000..99a289b5 --- /dev/null +++ b/test/demo.data.loader/README.md @@ -0,0 +1,193 @@ +# Demo Data Loader + +A Python script that imports demo data from a CSV file into the Query Orchestration API. This tool is used to populate the system with sample documents and field extraction records for demos and testing. + +## Overview + +The script performs the following operations: + +1. **Create Demo Client** - Creates a new client in the system to hold the demo data +2. **Upload Documents** - For each unique file in the CSV, creates and uploads a unique PDF document +3. **Create Field Extractions** - Imports the field extraction data from the CSV for each document +4. **Verify Records** - Retrieves and validates the created records match the input data + +## Requirements + +- Python 3.x (standard library only - no third-party packages required) +- Access to a running Query Orchestration API instance + +## Usage + +```bash +python3 demo_data_loader.py [jwt_token] +python3 demo_data_loader.py ./SaaS_Demo_Data_Sam.csv http://localhost:8080 +# example for uat system with token +python3 demo_data_loader.py ./SaaS_Demo_Data_Sam.csv http://queryo-query-wheg72fhas6h-1773391169.us-east-2.elb.amazonaws.com tokenfile=token.txt +``` + +### Arguments + +| Argument | Required | Description | +| ----------- | -------- | --------------------------------------------------------- | +| `csv_path` | Yes | Path to the CSV file containing demo data | +| `base_url` | Yes | Base URL of the API (e.g., `http://localhost:8080`) | +| `jwt_token` | No | JWT token for authentication (placeholder for future use) | + +### Examples + +```bash +# Run against local development server +python3 demo_data_loader.py SaaS_Demo_Data_Sam.csv http://localhost:8080 + +# Run against a deployed environment with authentication +python3 demo_data_loader.py SaaS_Demo_Data_Sam.csv https://api.example.com "eyJhbGciOiJS..." +``` + +## CSV File Format + +The CSV file must have: + +- **Header row** - Column names in the first row +- **Data rows** - Multiple rows where rows with the same `FILE_NAME` belong to the same document +- **Single-value fields** (columns 1-18) - Should be identical for all rows with the same `FILE_NAME` +- **Array fields** (columns 19+) - Can vary per row, forming multiple array items per document + +### Column Mapping + +#### Single-Value Fields (Columns 1-18) + +| Column | API Field | +| ----------------------------- | -------------------------- | +| FILE_NAME | fileName | +| CONTRACT_TITLE | contractTitle | +| AARETE_DERIVED_AMENDMENT_NUM | aareteDerivedAmendmentNum | +| CLIENT_NAME | clientName | +| PAYER_NAME | payerName | +| PAYER_STATE | payerState | +| PROVIDER_STATE | providerState | +| FILENAME_TIN | filenameTin | +| PROV_GROUP_TIN | provGroupTin | +| PROV_GROUP_NPI | provGroupNpi | +| PROV_GROUP_NAME_FULL | provGroupNameFull | +| PROV_OTHER_TIN | provOtherTin | +| PROV_OTHER_NPI | provOtherNpi | +| PROV_OTHER_NAME_FULL | provOtherNameFull | +| AARETE_DERIVED_EFFECTIVE_DT | aareteDerivedEffectiveDt | +| AARETE_DERIVED_TERMINATION_DT | aareteDerivedTerminationDt | +| AUTO_RENEWAL_IND | autoRenewalInd | +| AUTO_RENEWAL_TERM | autoRenewalTerm | + +#### Array Fields (Columns 19+) + +See the full mapping in the script source code. Array fields include: + +- Exhibit information (title, page) +- Provider information (TIN, NPI, name) +- Claim type, product, LOB, program, network +- Taxonomy and specialty codes +- Reimbursement terms and rates +- Grouper information +- Outlier and stop-loss provisions +- Facility adjustments (DSH, IME, NTAP, etc.) +- Rate escalator information + +## Data Type Conversions + +The script automatically converts CSV values to appropriate types: + +| Type | Fields | Conversion | +| ------- | ------------------------------------ | -------------------------------------- | +| Boolean | `*Ind` fields | Y/N/TRUE/FALSE -> true/false | +| Integer | `aareteDerivedAmendmentNum` | String -> integer | +| Numeric | `*Rate`, `*Amt`, `*Threshold` fields | String -> float (handles %, $, commas) | +| Date | `*Dt` fields | M/D/YYYY -> YYYY-MM-DD | + +## Output + +The script provides detailed progress output: + +``` +============================================================ + Demo Data Loader +============================================================ + CSV File: SaaS_Demo_Data_Sam.csv + Base URL: http://localhost:8080 + JWT Token: not provided + +============================================================ + Step 1: Reading CSV File +============================================================ + [OK] Read 220 data rows with 130 columns + +============================================================ + Step 2: Analyzing Data +============================================================ + [OK] Found 23 unique documents to import + - 952436878_Providence MC_Amd01_01.2025: 1 array item(s) + - 952436878_Providence MC_Amd02_01.2025: 18 array item(s) + ... + +============================================================ + Summary +============================================================ + Client ID: demo-data-loader-1769734603 + Documents uploaded: 23 + Field extractions created: 23 + Verifications passed: 23 + Verifications failed: 0 + [OK] All operations completed successfully! +``` + +## Exit Codes + +| Code | Meaning | +| ---- | ---------------------------------------------------------------------- | +| 0 | All operations completed successfully | +| 1 | Error occurred (missing arguments, file read error, API failure, etc.) | + +## Rate Limiting + +The script includes: + +- Automatic retry with exponential backoff for 429 (rate limit) responses +- Delays between API calls to avoid triggering rate limits + +## API Endpoints Used + +| Method | Endpoint | Purpose | +| ------ | ------------------------------------ | ----------------------- | +| POST | `/client` | Create demo client | +| POST | `/client/{id}/document` | Upload document | +| GET | `/client/{id}/document` | List documents | +| GET | `/document/{id}` | Get document details | +| POST | `/field-extractions` | Create field extraction | +| GET | `/field-extractions?documentId={id}` | Verify field extraction | + +## Troubleshooting + +### "No documents available after upload" + +The document processing queue may be slow. Try: + +1. Check that the `storeEventRunner` service is running +2. Increase the `max_attempts` parameter in `wait_for_documents()` + +### Rate Limit Errors (429) + +The script handles rate limits automatically with retries. If you still see errors: + +1. Increase delays between operations in the script +2. Reduce the number of documents being imported at once + +### "Failed to create extraction" + +Check that: + +1. The document exists in the system +2. The field values are valid (proper date formats, numeric values, etc.) + +## Source Files + +- `demo_data_loader.py` - Main script +- `SaaS_Demo_Data_Sam.csv` - Sample CSV data file +- `README.md` - This documentation diff --git a/test/demo.data.loader/demo_data_loader.py b/test/demo.data.loader/demo_data_loader.py new file mode 100644 index 00000000..edab60bc --- /dev/null +++ b/test/demo.data.loader/demo_data_loader.py @@ -0,0 +1,1055 @@ +#!/usr/bin/env python3 +""" +Demo Data Loader for Query Orchestration API + +This script imports demo data from a CSV file into the Query Orchestration API. +It creates a demo client, uploads unique PDF documents for each file in the CSV, +and creates field extraction records with both single-value and array fields. + +The CSV is expected to have: +- One header row with column names +- Multiple data rows where rows with the same FILE_NAME belong to the same document +- Single-value fields (columns 1-18) that are identical for rows with the same FILE_NAME +- Array fields (columns 19+) that vary per row, forming multiple array items per document + +Usage: + python3 demo_data_loader.py [jwt_token] + +Arguments: + csv_path - Path to the CSV file containing demo data + base_url - Base URL of the API (e.g., http://localhost:8080) + jwt_token - Optional JWT token for authentication (placeholder for future use) + +Example: + python3 demo_data_loader.py SaaS_Demo_Data_Sam.csv http://localhost:8080 +""" + +import csv +import json +import sys +import time +import urllib.request +import urllib.error +import urllib.parse +from collections import defaultdict +from datetime import datetime +from typing import Dict, List, Optional, Any, Tuple + + +# Column indices for single-value fields (0-17, corresponding to columns 1-18 in CSV) +SINGLE_FIELD_COLUMNS = { + 0: "fileName", # FILE_NAME + 1: "contractTitle", # CONTRACT_TITLE + 2: "aareteDerivedAmendmentNum", # AARETE_DERIVED_AMENDMENT_NUM + 3: "clientName", # CLIENT_NAME + 4: "payerName", # PAYER_NAME + 5: "payerState", # PAYER_STATE + 6: "providerState", # PROVIDER_STATE + 7: "filenameTin", # FILENAME_TIN + 8: "provGroupTin", # PROV_GROUP_TIN + 9: "provGroupNpi", # PROV_GROUP_NPI + 10: "provGroupNameFull", # PROV_GROUP_NAME_FULL + 11: "provOtherTin", # PROV_OTHER_TIN + 12: "provOtherNpi", # PROV_OTHER_NPI + 13: "provOtherNameFull", # PROV_OTHER_NAME_FULL + 14: "aareteDerivedEffectiveDt", # AARETE_DERIVED_EFFECTIVE_DT + 15: "aareteDerivedTerminationDt",# AARETE_DERIVED_TERMINATION_DT + 16: "autoRenewalInd", # AUTO_RENEWAL_IND + 17: "autoRenewalTerm", # AUTO_RENEWAL_TERM +} + +# Column indices for array fields (18+, corresponding to columns 19+ in CSV) +ARRAY_FIELD_COLUMNS = { + 18: "exhibitTitle", # EXHIBIT_TITLE + 19: "exhibitPage", # EXHIBIT_PAGE + 20: "reimbProvTin", # REIMB_PROV_TIN + 21: "reimbProvNpi", # REIMB_PROV_NPI + 22: "reimbProvName", # REIMB_PROV_NAME + 23: "reimbEffectiveDt", # REIMB_EFFECTIVE_DT + 24: "reimbTerminationDt", # REIMB_TERMINATION_DT + 25: "aareteDerivedClaimTypeCd", # AARETE_DERIVED_CLAIM_TYPE_CD + 26: "aareteDerivedProduct", # AARETE_DERIVED_PRODUCT + 27: "aareteDerivedLob", # AARETE_DERIVED_LOB + 28: "aareteDerivedProgram", # AARETE_DERIVED_PROGRAM + 29: "aareteDerivedNetwork", # AARETE_DERIVED_NETWORK + 30: "lobProgramRelationship", # LOB_PROGRAM_RELATIONSHIP + 31: "lobProductRelationship", # LOB_PRODUCT_RELATIONSHIP + 32: "aareteDerivedProvType", # AARETE_DERIVED_PROV_TYPE + 33: "provTaxonomyCd", # PROV_TAXONOMY_CD + 34: "provTaxonomyCdDesc", # PROV_TAXONOMY_CD_DESC + 35: "provSpecialtyCd", # PROV_SPECIALTY_CD + 36: "provSpecialtyCdDesc", # PROV_SPECIALTY_CD_DESC + 37: "placeOfServiceCd", # PLACE_OF_SERVICE_CD + 38: "placeOfServiceCdDesc", # PLACE_OF_SERVICE_CD_DESC + 39: "billTypeCd", # BILL_TYPE_CD + 40: "billTypeCdDesc", # BILL_TYPE_CD_DESC + 41: "patientAgeMin", # PATIENT_AGE_MIN + 42: "patientAgeMax", # PATIENT_AGE_MAX + 43: "reimbTerm", # REIMB_TERM + 44: "carveoutInd", # CARVEOUT_IND + 45: "carveoutCd", # CARVEOUT_CD + 46: "lesserOfInd", # LESSER_OF_IND + 47: "greaterOfInd", # GREATER_OF_IND + 48: "aareteDerivedReimbMethod", # AARETE_DERIVED_REIMB_METHOD + 49: "unitOfMeasure", # UNIT_OF_MEASURE + 50: "reimbPctRate", # REIMB_PCT_RATE + 51: "reimbFeeRate", # REIMB_FEE_RATE + 52: "reimbConversionFactor", # REIMB_CONVERSION_FACTOR + 53: "triggerCapThresholdAmt", # TRIGGER_CAP_THRESHOLD_AMT + 54: "triggerBaseThreshold", # TRIGGER_BASE_THRESHOLD + 55: "defaultInd", # DEFAULT_IND + 56: "additionDesc", # ADDITION_DESC + 57: "additionMaxPctRateInc", # ADDITION_MAX_PCT_RATE_INC + 58: "additionMaxFeeRateInc", # ADDITION_MAX_FEE_RATE_INC + 59: "aareteDerivedAdditionRateChangeTimeline", # AARETE_DERIVED_ADDITION_RATE_CHANGE_TIMELINE + 60: "aareteDerivedFeeSchedule", # AARETE_DERIVED_FEE_SCHEDULE + 61: "aareteDerivedFeeScheduleVersion", # AARETE_DERIVED_FEE_SCHEDULE_VERSION + 62: "serviceTerm", # SERVICE_TERM + 63: "cpt4ProcCd", # CPT4_PROC_CD + 64: "cpt4ProcCdDesc", # CPT4_PROC_CD_DESC + 65: "cpt4ProcMod", # CPT4_PROC_MOD + 66: "cpt4ProcModDesc", # CPT4_PROC_MOD_DESC + 67: "revenueCd", # REVENUE_CD + 68: "revenueCdDesc", # REVENUE_CD_DESC + 69: "diagCd", # DIAG_CD + 70: "diagCdDesc", # DIAG_CD_DESC + 71: "ndcCd", # NDC_CD + 72: "ndcCdDesc", # NDC_CD_DESC + 73: "claimAdmitTypeCd", # CLAIM_ADMIT_TYPE_CD + 74: "authAdmitTypeDesc", # AUTH_ADMIT_TYPE_DESC + 75: "claimStatusCd", # CLAIM_STATUS_CD + 76: "claimStatusCdDesc", # CLAIM_STATUS_CD_DESC + 77: "grouperType", # GROUPER_TYPE + 78: "grouperCd", # GROUPER_CD + 79: "grouperCdDesc", # GROUPER_CD_DESC + 80: "grouperPctRate", # GROUPER_PCT_RATE + 81: "grouperBaseRate", # GROUPER_BASE_RATE + 82: "aareteDerivedGrouperVersion", # AARETE_DERIVED_GROUPER_VERSION + 83: "grouperAlternativeLevelOfCare", # GROUPER_ALTERNATIVE_LEVEL_OF_CARE + 84: "grouperSeverityInd", # GROUPER_SEVERITY_IND + 85: "grouperSeverity", # GROUPER_SEVERITY + 86: "grouperRiskOfMortalitySubclass", # GROUPER_RISK_OF_MORTALITY_SUBCLASS + 87: "grouperTransferInd", # GROUPER_TRANSFER_IND + 88: "grouperReadmissionsInd", # GROUPER_READMISSIONS_IND + 89: "grouperHacInd", # GROUPER_HAC_IND + 90: "outlierTerm", # OUTLIER_TERM + 91: "outlierFirstDollarInd", # OUTLIER_FIRST_DOLLAR_IND + 92: "rangeNbrDays", # RANGE_NBR_DAYS + 93: "outlierFixedLossNbrDaysThreshold", # OUTLIER_FIXED_LOSS_NBR_DAYS_THRESHOLD + 94: "outlierFixedLossThreshold", # OUTLIER_FIXED_LOSS_THRESHOLD + 95: "outlierMaximum", # OUTLIER_MAXIMUM + 96: "outlierMaximumFrequency", # OUTLIER_MAXIMUM_FREQUENCY + 97: "outlierPctRate", # OUTLIER_PCT_RATE + 98: "outlierExclusionCd", # OUTLIER_EXCLUSION_CD + 99: "outlierExclusionCdDesc", # OUTLIER_EXCLUSION_CD_DESC + 100: "facilityAdjustmentTerm", # FACILITY_ADJUSTMENT_TERM + 101: "dshInd", # DSH_IND + 102: "dshPctRate", # DSH_PCT_RATE + 103: "dshFeeRate", # DSH_FEE_RATE + 104: "imeInd", # IME_IND + 105: "imePctRate", # IME_PCT_RATE + 106: "imeFeeRate", # IME_FEE_RATE + 107: "ntapInd", # NTAP_IND + 108: "ntapPctRate", # NTAP_PCT_RATE + 109: "ntapFeeRate", # NTAP_FEE_RATE + 110: "ucInd", # UC_IND + 111: "ucPctRate", # UC_PCT_RATE + 112: "ucFeeRate", # UC_FEE_RATE + 113: "gmeInd", # GME_IND + 114: "gmePctRate", # GME_PCT_RATE + 115: "gmeFeeRate", # GME_FEE_RATE + 116: "rateEscalatorInd", # RATE_ESCALATOR_IND + 117: "rateEscalatorDesc", # RATE_ESCALATOR_DESC + 118: "rateEscalatorMaxRateIncPct", # RATE_ESCALATOR_MAX_RATE_INC_PCT + 119: "rateEscalatorRateChangeTimeline", # RATE_ESCALATOR_RATE_CHANGE_TIMELINE + 120: "stopLossTerm", # STOP_LOSS_TERM + 121: "stopLossFirstDollarInd", # STOP_LOSS_FIRST_DOLLAR_IND + 122: "stopLossRangeNbrDays", # STOP_LOSS_RANGE_NBR_DAYS + 123: "stopLossFixedLossThreshold", # STOP_LOSS_FIXED_LOSS_THRESHOLD + 124: "stopLossMaximum", # STOP_LOSS_MAXIMUM + 125: "stopLossMaximumFrequency", # STOP_LOSS_MAXIMUM_FREQUENCY + 126: "stopLossDailyMaxRate", # STOP_LOSS_DAILY_MAX_RATE + 127: "stopLossPctRateOnExcessCharges", # STOP_LOSS_PCT_RATE_ON_EXCESS_CHARGES + 128: "stopLossExclusionCd", # STOP_LOSS_EXCLUSION_CD + 129: "stopLossExclusionDesc", # STOP_LOSS_EXCLUSION_DESC +} + +# Fields that should be converted to boolean +BOOLEAN_FIELDS = { + "autoRenewalInd", "carveoutInd", "lesserOfInd", "greaterOfInd", + "defaultInd", "grouperSeverityInd", "grouperTransferInd", + "grouperReadmissionsInd", "grouperHacInd", "outlierFirstDollarInd", + "dshInd", "imeInd", "ntapInd", "ucInd", "gmeInd", "rateEscalatorInd", + "stopLossFirstDollarInd" +} + +# Fields that should be converted to integer +INTEGER_FIELDS = {"aareteDerivedAmendmentNum"} + +# Fields that should be converted to float/number +NUMERIC_FIELDS = { + "reimbPctRate", "reimbFeeRate", "reimbConversionFactor", + "triggerCapThresholdAmt", "triggerBaseThreshold", + "additionMaxPctRateInc", "additionMaxFeeRateInc", + "grouperPctRate", "grouperBaseRate", + "outlierFixedLossNbrDaysThreshold", "outlierFixedLossThreshold", + "outlierMaximum", "outlierMaximumFrequency", "outlierPctRate", + "dshPctRate", "dshFeeRate", "imePctRate", "imeFeeRate", + "ntapPctRate", "ntapFeeRate", "ucPctRate", "ucFeeRate", + "gmePctRate", "gmeFeeRate", "rateEscalatorMaxRateIncPct", + "rateEscalatorRateChangeTimeline", "stopLossRangeNbrDays", + "stopLossFixedLossThreshold", "stopLossMaximum", + "stopLossMaximumFrequency", "stopLossDailyMaxRate", + "stopLossPctRateOnExcessCharges" +} + +# Fields that should be formatted as dates (YYYY-MM-DD) +DATE_FIELDS = { + "aareteDerivedEffectiveDt", "aareteDerivedTerminationDt", + "reimbEffectiveDt", "reimbTerminationDt" +} + + +def read_token_file(filepath: str) -> Optional[str]: + """ + Read a JWT token from a file. + + Args: + filepath: Path to the file containing the token. + + Returns: + The token string (stripped of whitespace) or None if file cannot be read. + """ + try: + with open(filepath, "r", encoding="utf-8") as f: + token = f.read().strip() + if token: + return token + return None + except (IOError, OSError) as e: + print(f" [ERROR] Failed to read token file: {e}") + return None + + +def print_header(msg: str) -> None: + """Print a formatted section header.""" + print(f"\n{'=' * 60}") + print(f" {msg}") + print(f"{'=' * 60}") + + +def print_success(msg: str) -> None: + """Print a success message.""" + print(f" [OK] {msg}") + + +def print_error(msg: str) -> None: + """Print an error message.""" + print(f" [ERROR] {msg}") + + +def print_info(msg: str) -> None: + """Print an informational message.""" + print(f" {msg}") + + +def parse_boolean(value: str) -> Optional[bool]: + """ + Parse a string value to boolean. + + Args: + value: String value like 'Y', 'N', 'true', 'false', etc. + + Returns: + Boolean value or None if empty/invalid. + """ + if not value or value.strip() == "": + return None + v = value.strip().upper() + if v in ("Y", "YES", "TRUE", "1"): + return True + if v in ("N", "NO", "FALSE", "0"): + return False + return None + + +def parse_integer(value: str) -> Optional[int]: + """ + Parse a string value to integer. + + Args: + value: String representation of an integer. + + Returns: + Integer value or None if empty/invalid. + """ + if not value or value.strip() == "": + return None + try: + return int(value.strip()) + except ValueError: + return None + + +def parse_numeric(value: str) -> Optional[float]: + """ + Parse a string value to float, handling percentages and special characters. + + Args: + value: String representation of a number (may include % or other chars). + + Returns: + Float value or None if empty/invalid. + """ + if not value or value.strip() == "": + return None + v = value.strip() + # Remove percentage sign and other common suffixes + v = v.replace("%", "").replace("$", "").replace(",", "").strip() + try: + return float(v) + except ValueError: + return None + + +def parse_date(value: str) -> Optional[str]: + """ + Parse a date string and convert to YYYY-MM-DD format. + + Args: + value: Date string in various formats (M/D/YYYY, MM/DD/YYYY, etc.) + + Returns: + Date in YYYY-MM-DD format or None if empty/invalid. + """ + if not value or value.strip() == "": + return None + v = value.strip() + + # Try common date formats + formats = [ + "%m/%d/%Y", # 1/1/2025 or 01/01/2025 + "%Y-%m-%d", # 2025-01-01 + "%d/%m/%Y", # 01/01/2025 (European) + "%Y/%m/%d", # 2025/01/01 + ] + + for fmt in formats: + try: + dt = datetime.strptime(v, fmt) + return dt.strftime("%Y-%m-%d") + except ValueError: + continue + + return None + + +def convert_field_value(field_name: str, value: str) -> Any: + """ + Convert a field value to its appropriate type based on field name. + + Args: + field_name: The API field name. + value: The raw string value from CSV. + + Returns: + Converted value in appropriate type. + """ + if field_name in BOOLEAN_FIELDS: + return parse_boolean(value) + if field_name in INTEGER_FIELDS: + return parse_integer(value) + if field_name in NUMERIC_FIELDS: + return parse_numeric(value) + if field_name in DATE_FIELDS: + return parse_date(value) + # Default: return as string (None if empty) + if not value or value.strip() == "": + return None + return value.strip() + + +def api_request( + base_url: str, + endpoint: str, + method: str = "GET", + data: Optional[Dict] = None, + jwt_token: Optional[str] = None, + max_retries: int = 3 +) -> Tuple[int, Any]: + """ + Make an HTTP request to the API with retry logic for rate limiting. + + Args: + base_url: Base URL of the API. + endpoint: API endpoint path. + method: HTTP method (GET, POST, etc.) + data: Request body data (will be JSON encoded). + jwt_token: Optional JWT token for authorization. + max_retries: Maximum number of retries for rate limiting. + + Returns: + Tuple of (HTTP status code, response body as dict/list or error string). + """ + url = f"{base_url.rstrip('/')}{endpoint}" + headers = {"Content-Type": "application/json"} + + if jwt_token: + headers["Authorization"] = f"Bearer {jwt_token}" + + body = None + if data is not None: + body = json.dumps(data).encode("utf-8") + + delay = 1.0 + for attempt in range(max_retries + 1): + req = urllib.request.Request(url, data=body, headers=headers, method=method) + + try: + with urllib.request.urlopen(req, timeout=30) as resp: + status = resp.status + content = resp.read().decode("utf-8") + if content: + return status, json.loads(content) + return status, {} + except urllib.error.HTTPError as e: + content = e.read().decode("utf-8") if e.fp else "" + # Retry on rate limit + if e.code == 429 and attempt < max_retries: + time.sleep(delay) + delay *= 2 # Exponential backoff + continue + try: + return e.code, json.loads(content) if content else {"error": str(e)} + except json.JSONDecodeError: + return e.code, {"error": content or str(e)} + except urllib.error.URLError as e: + return 0, {"error": str(e)} + + return 429, {"error": "Rate limit exceeded after retries"} + + +def upload_document( + base_url: str, + client_id: str, + filename: str, + content: bytes, + jwt_token: Optional[str] = None +) -> Tuple[int, Any]: + """ + Upload a document via multipart/form-data. + + Args: + base_url: Base URL of the API. + client_id: Client ID to upload document to. + filename: Name of the file being uploaded. + content: File content as bytes. + jwt_token: Optional JWT token for authorization. + + Returns: + Tuple of (HTTP status code, response body). + """ + url = f"{base_url.rstrip('/')}/client/{client_id}/document" + boundary = f"----WebKitFormBoundary{int(time.time() * 1000)}" + + # Build multipart form data - content type must be application/octet-stream + body_parts = [] + body_parts.append(f"--{boundary}".encode()) + body_parts.append( + f'Content-Disposition: form-data; name="file"; filename="{filename}"'.encode() + ) + body_parts.append(b"Content-Type: application/octet-stream") + body_parts.append(b"") + body_parts.append(content) + body_parts.append(f"--{boundary}--".encode()) + + body = b"\r\n".join(body_parts) + + headers = { + "Content-Type": f"multipart/form-data; boundary={boundary}", + "Content-Length": str(len(body)) + } + + if jwt_token: + headers["Authorization"] = f"Bearer {jwt_token}" + + req = urllib.request.Request(url, data=body, headers=headers, method="POST") + + try: + with urllib.request.urlopen(req, timeout=60) as resp: + status = resp.status + resp_content = resp.read().decode("utf-8") + if resp_content: + return status, json.loads(resp_content) + return status, {} + except urllib.error.HTTPError as e: + err_content = e.read().decode("utf-8") if e.fp else "" + try: + return e.code, json.loads(err_content) if err_content else {"error": str(e)} + except json.JSONDecodeError: + return e.code, {"error": err_content or str(e)} + except urllib.error.URLError as e: + return 0, {"error": str(e)} + + +def create_pdf(unique_text: str) -> bytes: + """ + Create a minimal valid PDF file with unique content. + + The PDF contains the unique_text which makes its hash unique. + + Args: + unique_text: Text to embed in the PDF to make it unique. + + Returns: + PDF file content as bytes. + """ + stream_content = f"""BT +/F1 12 Tf +50 700 Td +({unique_text}) Tj +ET""" + stream_length = len(stream_content) + + pdf = f"""%PDF-1.4 +%\xe2\xe3\xcf\xd3 +1 0 obj +<< + /Type /Catalog + /Pages 2 0 R +>> +endobj +2 0 obj +<< + /Type /Pages + /Kids [3 0 R] + /Count 1 +>> +endobj +3 0 obj +<< + /Type /Page + /Parent 2 0 R + /MediaBox [0 0 612 792] + /Resources << + /Font << + /F1 << + /Type /Font + /Subtype /Type1 + /BaseFont /Helvetica + >> + >> + >> + /Contents 4 0 R +>> +endobj +4 0 obj +<< + /Length {stream_length} +>> +stream +{stream_content} +endstream +endobj +xref +0 5 +0000000000 65535 f +0000000015 00000 n +0000000066 00000 n +0000000125 00000 n +0000000330 00000 n +trailer +<< + /Size 5 + /Root 1 0 R +>> +startxref +430 +%%EOF""" + + return pdf.encode("latin-1") + + +def read_csv_file(csv_path: str) -> Tuple[List[str], List[List[str]]]: + """ + Read and parse the CSV file. + + Args: + csv_path: Path to the CSV file. + + Returns: + Tuple of (headers list, data rows list). + """ + with open(csv_path, "r", encoding="utf-8-sig") as f: + reader = csv.reader(f) + headers = next(reader) + rows = list(reader) + return headers, rows + + +def group_rows_by_filename(rows: List[List[str]]) -> Dict[str, List[List[str]]]: + """ + Group CSV rows by FILE_NAME (first column). + + Args: + rows: List of CSV data rows. + + Returns: + Dictionary mapping file names to their rows. + """ + groups = defaultdict(list) + for row in rows: + if row: # Skip empty rows + filename = row[0] if row else "" + if filename: + groups[filename].append(row) + return dict(groups) + + +def extract_single_fields(row: List[str]) -> Dict[str, Any]: + """ + Extract single-value fields from a CSV row. + + Args: + row: A CSV data row. + + Returns: + Dictionary of single field name -> value. + """ + fields = {} + for col_idx, field_name in SINGLE_FIELD_COLUMNS.items(): + if col_idx < len(row): + value = convert_field_value(field_name, row[col_idx]) + if value is not None: + fields[field_name] = value + return fields + + +def extract_array_field_item(row: List[str]) -> Dict[str, Any]: + """ + Extract array field item from a CSV row. + + Args: + row: A CSV data row. + + Returns: + Dictionary of array field name -> value. + """ + item = {} + for col_idx, field_name in ARRAY_FIELD_COLUMNS.items(): + if col_idx < len(row): + value = convert_field_value(field_name, row[col_idx]) + if value is not None: + item[field_name] = value + return item + + +def create_client(base_url: str, jwt_token: Optional[str] = None) -> Optional[str]: + """ + Create a demo client via the API. + + Args: + base_url: Base URL of the API. + jwt_token: Optional JWT token. + + Returns: + Client ID if successful, None otherwise. + """ + timestamp = int(time.time()) + client_data = { + "id": f"demo-data-loader-{timestamp}", + "name": f"Demo Data Loader Client {timestamp}" + } + + print_info(f"Creating client: {client_data['name']}") + status, response = api_request(base_url, "/client", "POST", client_data, jwt_token) + + if status == 201: + client_id = response.get("id") + print_success(f"Client created: {client_id}") + return client_id + else: + print_error(f"Failed to create client: {status} - {response}") + return None + + +def upload_documents( + base_url: str, + client_id: str, + filenames: List[str], + jwt_token: Optional[str] = None +) -> Dict[str, str]: + """ + Upload unique PDF documents for each filename. + + Args: + base_url: Base URL of the API. + client_id: Client ID to upload to. + filenames: List of unique filenames from CSV. + jwt_token: Optional JWT token. + + Returns: + Dictionary mapping original filename to document ID. + """ + filename_to_doc_id = {} + + for i, filename in enumerate(filenames): + # Create unique PDF with timestamp and index + unique_text = f"Demo Document: {filename} - Created: {datetime.now().isoformat()} - Index: {i}" + pdf_content = create_pdf(unique_text) + pdf_filename = f"{filename}.pdf" + + print_info(f"Uploading: {pdf_filename}") + status, response = upload_document( + base_url, client_id, pdf_filename, pdf_content, jwt_token + ) + + if status == 200: + # Response may contain document info or we need to fetch it + doc_id = response.get("id") or response.get("document_id") + if doc_id: + filename_to_doc_id[filename] = doc_id + print_success(f"Uploaded: {pdf_filename} -> {doc_id}") + else: + print_info(f"Uploaded {pdf_filename} (processing async)") + # Will need to retrieve document ID later + else: + print_error(f"Failed to upload {pdf_filename}: {status} - {response}") + + # Delay to avoid rate limiting + time.sleep(0.5) + + return filename_to_doc_id + + +def wait_for_documents( + base_url: str, + client_id: str, + expected_count: int, + jwt_token: Optional[str] = None, + max_attempts: int = 30 +) -> List[Dict]: + """ + Wait for documents to be processed and return their details. + + Args: + base_url: Base URL of the API. + client_id: Client ID to query. + expected_count: Number of documents expected. + jwt_token: Optional JWT token. + max_attempts: Maximum polling attempts. + + Returns: + List of document records with full details including filename. + """ + print_info(f"Waiting for {expected_count} documents to be processed...") + + doc_count = 0 + doc_ids = [] + + for attempt in range(max_attempts): + status, response = api_request( + base_url, f"/client/{client_id}/document", "GET", jwt_token=jwt_token + ) + + if status == 200 and isinstance(response, list): + doc_count = len(response) + doc_ids = [d.get("id") for d in response if d.get("id")] + print_info(f" Attempt {attempt + 1}: {doc_count}/{expected_count} documents") + + if doc_count >= expected_count: + print_success(f"All {doc_count} documents are available") + break + + time.sleep(1) + + if doc_count == 0: + print_info("Timeout: No documents available") + return [] + + # Fetch full details for each document to get filenames + print_info("Fetching document details...") + full_documents = [] + for doc_id in doc_ids: + status, doc_detail = api_request( + base_url, f"/document/{doc_id}", "GET", jwt_token=jwt_token + ) + if status == 200: + full_documents.append(doc_detail) + time.sleep(0.05) # Small delay to avoid rate limiting + + return full_documents + + +def create_field_extraction( + base_url: str, + document_id: str, + single_fields: Dict[str, Any], + array_fields: List[Dict[str, Any]], + jwt_token: Optional[str] = None +) -> bool: + """ + Create a field extraction record for a document. + + Args: + base_url: Base URL of the API. + document_id: Document ID to create extraction for. + single_fields: Single-value fields dictionary. + array_fields: List of array field items. + jwt_token: Optional JWT token. + + Returns: + True if successful, False otherwise. + """ + extraction_data = { + "documentId": document_id, + "singleFields": single_fields, + "arrayFields": array_fields, + "createdBy": "demo-data-loader@example.com" + } + + status, response = api_request( + base_url, "/field-extractions", "POST", extraction_data, jwt_token + ) + + if status == 201: + version = response.get("version", "?") + print_success(f"Field extraction created: version={version}") + return True + else: + print_error(f"Failed to create extraction: {status} - {response}") + return False + + +def verify_field_extractions( + base_url: str, + document_ids: List[str], + csv_data: Dict[str, Dict], + jwt_token: Optional[str] = None +) -> Tuple[int, int]: + """ + Verify that field extractions match the CSV input data. + + Args: + base_url: Base URL of the API. + document_ids: List of document IDs to verify. + csv_data: Dictionary mapping filename to expected extraction data. + jwt_token: Optional JWT token. + + Returns: + Tuple of (success_count, failure_count). + """ + success_count = 0 + failure_count = 0 + + for doc_id in document_ids: + status, response = api_request( + base_url, f"/field-extractions?documentId={doc_id}", "GET", jwt_token=jwt_token + ) + + if status == 200: + single_fields = response.get("singleFields", {}) + array_fields = response.get("arrayFields", []) + filename = single_fields.get("fileName", "") + + # Basic validation: check that fields exist + if single_fields and array_fields: + print_success( + f"Verified {doc_id}: {len(single_fields)} single fields, " + f"{len(array_fields)} array items" + ) + success_count += 1 + else: + print_error(f"Incomplete extraction for {doc_id}") + failure_count += 1 + elif status == 404: + print_error(f"No extraction found for {doc_id}") + failure_count += 1 + else: + print_error(f"Failed to verify {doc_id}: {status}") + failure_count += 1 + + return success_count, failure_count + + +def parse_arguments() -> Tuple[str, str, Optional[str]]: + """ + Parse command line arguments. + + Supports: + - positional: csv_path, base_url, [jwt_token] + - named: tokenfile= to read token from file + + Returns: + Tuple of (csv_path, base_url, jwt_token or None). + """ + if len(sys.argv) < 3: + print("Usage: python3 demo_data_loader.py [jwt_token | tokenfile=]") + print("") + print("Arguments:") + print(" csv_path - Path to the CSV file containing demo data") + print(" base_url - Base URL of the API (e.g., http://localhost:8080)") + print(" jwt_token - Optional JWT token for authentication") + print(" tokenfile= - Optional path to file containing JWT token") + print("") + print("Examples:") + print(" python3 demo_data_loader.py SaaS_Demo_Data_Sam.csv http://localhost:8080") + print(" python3 demo_data_loader.py SaaS_Demo_Data_Sam.csv http://localhost:8080 tokenfile=./token.txt") + sys.exit(1) + + csv_path = sys.argv[1] + base_url = sys.argv[2] + jwt_token = None + + # Check for optional third argument + if len(sys.argv) > 3: + arg3 = sys.argv[3] + if arg3.startswith("tokenfile="): + # Read token from file + token_path = arg3[len("tokenfile="):] + jwt_token = read_token_file(token_path) + if jwt_token is None: + print(f" [ERROR] Could not read token from: {token_path}") + sys.exit(1) + else: + # Treat as direct token + jwt_token = arg3 + + return csv_path, base_url, jwt_token + + +def main(): + """Main entry point for the demo data loader script.""" + # Parse command line arguments + csv_path, base_url, jwt_token = parse_arguments() + + print_header("Demo Data Loader") + print_info(f"CSV File: {csv_path}") + print_info(f"Base URL: {base_url}") + if jwt_token: + # Show truncated token for security + token_preview = jwt_token[:20] + "..." if len(jwt_token) > 20 else jwt_token + print_info(f"JWT Token: {token_preview}") + else: + print_info("JWT Token: not provided") + + # Step 1: Read and parse CSV + print_header("Step 1: Reading CSV File") + try: + headers, rows = read_csv_file(csv_path) + print_success(f"Read {len(rows)} data rows with {len(headers)} columns") + except Exception as e: + print_error(f"Failed to read CSV: {e}") + sys.exit(1) + + # Step 2: Group rows by filename + print_header("Step 2: Analyzing Data") + file_groups = group_rows_by_filename(rows) + unique_files = list(file_groups.keys()) + print_success(f"Found {len(unique_files)} unique documents to import") + + for filename in unique_files[:5]: # Show first 5 + row_count = len(file_groups[filename]) + print_info(f" - {filename}: {row_count} array item(s)") + if len(unique_files) > 5: + print_info(f" ... and {len(unique_files) - 5} more") + + # Step 3: Create demo client + print_header("Step 3: Creating Demo Client") + client_id = create_client(base_url, jwt_token) + if not client_id: + print_error("Cannot proceed without a client") + sys.exit(1) + + # Step 4: Upload documents + print_header("Step 4: Uploading Documents") + filename_to_doc_id = upload_documents(base_url, client_id, unique_files, jwt_token) + + # Step 5: Wait for documents to be processed + print_header("Step 5: Waiting for Document Processing") + documents = wait_for_documents(base_url, client_id, len(unique_files), jwt_token) + + if not documents: + print_error("No documents available after upload") + sys.exit(1) + + # Build mapping from filename to document ID + for doc in documents: + doc_filename = doc.get("filename", "") + doc_id = doc.get("id", "") + if not doc_id or not doc_filename: + continue + # Remove .pdf extension for matching + base_name = doc_filename + if base_name.lower().endswith(".pdf"): + base_name = base_name[:-4] + if base_name in file_groups: + filename_to_doc_id[base_name] = doc_id + + print_success(f"Mapped {len(filename_to_doc_id)} filenames to document IDs") + + # Debug: show any unmatched files + unmatched_csv = [f for f in file_groups.keys() if f not in filename_to_doc_id] + if unmatched_csv: + print_info(f"Unmatched CSV files ({len(unmatched_csv)}):") + for f in unmatched_csv[:5]: + print_info(f" - {f}") + + # Step 6: Create field extractions + print_header("Step 6: Creating Field Extractions") + extraction_success = 0 + extraction_failed = 0 + + for filename, doc_rows in file_groups.items(): + doc_id = filename_to_doc_id.get(filename) + if not doc_id: + print_error(f"No document ID for {filename}") + extraction_failed += 1 + continue + + print_info(f"Processing: {filename}") + + # Extract single fields from first row (they should be same for all rows) + single_fields = extract_single_fields(doc_rows[0]) + + # Extract array fields from all rows + array_fields = [] + for row in doc_rows: + array_item = extract_array_field_item(row) + if array_item: # Only add non-empty items + array_fields.append(array_item) + + # Create field extraction + if create_field_extraction(base_url, doc_id, single_fields, array_fields, jwt_token): + extraction_success += 1 + else: + extraction_failed += 1 + + # Delay to avoid rate limiting + time.sleep(0.5) + + print_success(f"Created {extraction_success} extractions, {extraction_failed} failed") + + # Step 7: Verify extractions + print_header("Step 7: Verifying Field Extractions") + doc_ids_to_verify = list(filename_to_doc_id.values()) + verified, failed = verify_field_extractions(base_url, doc_ids_to_verify, {}, jwt_token) + + # Final summary + print_header("Summary") + print_info(f"Client ID: {client_id}") + print_info(f"Documents uploaded: {len(documents)}") + print_info(f"Field extractions created: {extraction_success}") + print_info(f"Verifications passed: {verified}") + print_info(f"Verifications failed: {failed}") + + if extraction_failed == 0 and failed == 0: + print_success("All operations completed successfully!") + sys.exit(0) + else: + print_error(f"Some operations failed: {extraction_failed} extractions, {failed} verifications") + sys.exit(1) + + +if __name__ == "__main__": + main()