// Package queryapi: handler implementations for the CustomMetadataService // routes (POST /custom-metadata, GET /custom-metadata, // GET /custom-metadata/version, GET /custom-metadata/history) plus the // super-admin PATCH /super-admin/documents/{id}/schema route. // // Every mutating handler derives the actor from the JWT sub claim via // cognitoauth.GetUserSubject and never reads a createdBy field from the // request body — the generated request types do not even declare one. package queryapi import ( "encoding/json" "errors" "log/slog" "net/http" "strings" "time" "queryorchestration/internal/cognitoauth" "queryorchestration/internal/customschema" "github.com/google/uuid" "github.com/labstack/echo/v4" "github.com/oapi-codegen/nullable" openapi_types "github.com/oapi-codegen/runtime/types" ) // AssignDocumentSchema is PATCH /super-admin/documents/{id}/schema. The // customSchemaId field may be null (clear the binding) or a UUID (bind). func (s *Controllers) AssignDocumentSchema(ctx echo.Context, id openapi_types.UUID) error { actor, ok := cognitoauth.GetUserSubject(ctx) if !ok { return echo.NewHTTPError(http.StatusUnauthorized, "missing user subject") } var req DocumentSchemaAssignRequest if err := ctx.Bind(&req); err != nil { slog.Error("failed to parse assign schema request", "error", err) return echo.NewHTTPError(http.StatusBadRequest, "invalid request body") } // CustomSchemaId is a Nullable[UUID]: the caller can omit it // (unspecified), explicitly set it to null (clear the binding), or // provide a UUID (bind). Unspecified is treated as a 400 because the // admin must make an explicit choice. var schemaID *uuid.UUID switch { case req.CustomSchemaId.IsSpecified() && !req.CustomSchemaId.IsNull(): sid, err := req.CustomSchemaId.Get() if err != nil { return echo.NewHTTPError(http.StatusBadRequest, "invalid customSchemaId") } u := uuid.UUID(sid) schemaID = &u case req.CustomSchemaId.IsSpecified() && req.CustomSchemaId.IsNull(): // Explicit null → clear binding. schemaID stays nil. default: return echo.NewHTTPError(http.StatusBadRequest, "customSchemaId is required (use null to clear)") } result, err := s.svc.CustomSchema.AssignSchema(ctx.Request().Context(), uuid.UUID(id), schemaID, actor) if err != nil { return mapCustomMetadataError(err) } resp := DocumentSchemaAssignResponse{ DocumentId: openapi_types.UUID(result.DocumentID), } if result.CustomSchemaID != nil { resp.CustomSchemaId = nullable.NewNullableWithValue(openapi_types.UUID(*result.CustomSchemaID)) } else { resp.CustomSchemaId = nullable.NewNullNullable[openapi_types.UUID]() } if result.SchemaName != nil { resp.SchemaName = nullable.NewNullableWithValue(*result.SchemaName) } else { resp.SchemaName = nullable.NewNullNullable[string]() } if result.SchemaVersion != nil { v := int32(*result.SchemaVersion) //nolint:gosec // per-lineage monotonic counter resp.SchemaVersion = nullable.NewNullableWithValue(v) } else { resp.SchemaVersion = nullable.NewNullNullable[int32]() } return ctx.JSON(http.StatusOK, resp) } // SetCustomMetadata is POST /custom-metadata. func (s *Controllers) SetCustomMetadata(ctx echo.Context) error { actor, ok := cognitoauth.GetUserSubject(ctx) if !ok { return echo.NewHTTPError(http.StatusUnauthorized, "missing user subject") } var req CustomMetadataRequest if err := ctx.Bind(&req); err != nil { slog.Error("failed to parse custom metadata request", "error", err) return echo.NewHTTPError(http.StatusBadRequest, "invalid request body") } metadataBytes, err := json.Marshal(req.Metadata) if err != nil { return echo.NewHTTPError(http.StatusBadRequest, "invalid metadata JSON") } input := customschema.SetMetadataInput{ DocumentID: uuid.UUID(req.DocumentId), Metadata: metadataBytes, } got, err := s.svc.CustomSchema.SetDocumentMetadata(ctx.Request().Context(), input, actor) if err != nil { return mapCustomMetadataError(err) } resp, err := metadataToResponse(got) if err != nil { return echo.NewHTTPError(http.StatusInternalServerError, "failed to encode metadata response") } return ctx.JSON(http.StatusCreated, resp) } // GetCurrentCustomMetadata is GET /custom-metadata?documentId=.... func (s *Controllers) GetCurrentCustomMetadata(ctx echo.Context, params GetCurrentCustomMetadataParams) error { if _, ok := cognitoauth.GetUserSubject(ctx); !ok { return echo.NewHTTPError(http.StatusUnauthorized, "missing user subject") } got, err := s.svc.CustomSchema.GetCurrentMetadata(ctx.Request().Context(), uuid.UUID(params.DocumentId)) if err != nil { return mapCustomMetadataError(err) } resp, err := metadataToResponse(got) if err != nil { return echo.NewHTTPError(http.StatusInternalServerError, "failed to encode metadata response") } return ctx.JSON(http.StatusOK, resp) } // GetCustomMetadataByVersion is GET /custom-metadata/version?documentId=...&version=N. func (s *Controllers) GetCustomMetadataByVersion(ctx echo.Context, params GetCustomMetadataByVersionParams) error { if _, ok := cognitoauth.GetUserSubject(ctx); !ok { return echo.NewHTTPError(http.StatusUnauthorized, "missing user subject") } got, err := s.svc.CustomSchema.GetMetadataByVersion(ctx.Request().Context(), uuid.UUID(params.DocumentId), int(params.Version)) if err != nil { return mapCustomMetadataError(err) } resp, err := metadataToResponse(got) if err != nil { return echo.NewHTTPError(http.StatusInternalServerError, "failed to encode metadata response") } return ctx.JSON(http.StatusOK, resp) } // customMetadataHistoryRow is a type alias onto the anonymous element // type inside the generated CustomMetadataHistoryResponse.Versions slice. // Structural identity with the generated type lets us append directly. type customMetadataHistoryRow = struct { CreatedAt time.Time `json:"createdAt"` CreatedBy string `json:"createdBy"` Version int32 `json:"version"` } // GetCustomMetadataHistory is GET /custom-metadata/history?documentId=...&limit=N&offset=M. func (s *Controllers) GetCustomMetadataHistory(ctx echo.Context, params GetCustomMetadataHistoryParams) error { if _, ok := cognitoauth.GetUserSubject(ctx); !ok { return echo.NewHTTPError(http.StatusUnauthorized, "missing user subject") } limit := 50 if params.Limit != nil { limit = int(*params.Limit) } offset := 0 if params.Offset != nil { offset = int(*params.Offset) } got, err := s.svc.CustomSchema.GetMetadataHistory(ctx.Request().Context(), uuid.UUID(params.DocumentId), limit, offset) if err != nil { return mapCustomMetadataError(err) } out := CustomMetadataHistoryResponse{ Versions: make([]customMetadataHistoryRow, 0, len(got)), } for _, v := range got { out.Versions = append(out.Versions, customMetadataHistoryRow{ Version: int32(v.Version), //nolint:gosec // metadata version is a per-doc monotonic counter, far below int32 ceiling CreatedAt: v.CreatedAt, CreatedBy: v.CreatedBy, }) } return ctx.JSON(http.StatusOK, out) } // mapCustomMetadataError translates a customschema.Service error coming out // of the metadata/assign-schema paths into an echo HTTP error with the // correct status code. func mapCustomMetadataError(err error) error { switch { case errors.Is(err, customschema.ErrDocumentNotFound): return echo.NewHTTPError(http.StatusNotFound, "document not found") case errors.Is(err, customschema.ErrMetadataVersionNotFound): return echo.NewHTTPError(http.StatusNotFound, "metadata version not found") case errors.Is(err, customschema.ErrDocumentNotBoundToSchema): return echo.NewHTTPError(http.StatusBadRequest, "document is not bound to a custom schema") case errors.Is(err, customschema.ErrMetadataPayloadTooLarge): return echo.NewHTTPError(http.StatusBadRequest, "metadata payload exceeds maximum size") case errors.Is(err, customschema.ErrSchemaNotFound): return echo.NewHTTPError(http.StatusNotFound, "schema not found") case errors.Is(err, customschema.ErrSchemaNotActive): return echo.NewHTTPError(http.StatusConflict, "schema is not active") case errors.Is(err, customschema.ErrSchemaClientMismatch): return echo.NewHTTPError(http.StatusConflict, "schema and document belong to different clients") case errors.Is(err, customschema.ErrDocumentHasCustomMetadata): return echo.NewHTTPError(http.StatusConflict, "document already has custom metadata") case errors.Is(err, customschema.ErrDocumentHasLegacyExtractions): return echo.NewHTTPError(http.StatusConflict, "document has legacy field extractions") } // Validator failures wrap "validate metadata". The history service // method surfaces limit/offset errors as plain strings. msg := err.Error() if strings.Contains(msg, "validate metadata") || strings.Contains(msg, "limit must be") || strings.Contains(msg, "offset must be") || strings.Contains(msg, "document id is required") || strings.Contains(msg, "version must be") { return echo.NewHTTPError(http.StatusBadRequest, msg) } slog.Error("custom metadata handler internal error", "error", err) return echo.NewHTTPError(http.StatusInternalServerError, "internal server error") } // metadataToResponse copies a customschema.CustomMetadata into the // generated CustomMetadataResponse. func metadataToResponse(m *customschema.CustomMetadata) (CustomMetadataResponse, error) { var metaMap map[string]interface{} if len(m.Metadata) > 0 { if err := json.Unmarshal(m.Metadata, &metaMap); err != nil { return CustomMetadataResponse{}, err } } resp := CustomMetadataResponse{ Id: openapi_types.UUID(m.ID), DocumentId: openapi_types.UUID(m.DocumentID), Metadata: metaMap, Version: int32(m.Version), //nolint:gosec // per-doc monotonic counter CreatedAt: m.CreatedAt, CreatedBy: m.CreatedBy, } if m.SchemaID != nil { resp.SchemaId = nullable.NewNullableWithValue(openapi_types.UUID(*m.SchemaID)) } else { resp.SchemaId = nullable.NewNullNullable[openapi_types.UUID]() } if m.SchemaName != nil { resp.SchemaName = nullable.NewNullableWithValue(*m.SchemaName) } else { resp.SchemaName = nullable.NewNullNullable[string]() } if m.SchemaVersion != nil { v := int32(*m.SchemaVersion) //nolint:gosec // per-lineage monotonic counter resp.SchemaVersion = nullable.NewNullableWithValue(v) } else { resp.SchemaVersion = nullable.NewNullNullable[int32]() } return resp, nil }