// Package queryapi: handler implementations for the M5 read-only // document endpoints. Plan §10 M5 exposes the M1 client-scoped repo // queries (GetDocumentEnrichedForClient, GetDocumentLabelsForClient, // GetDocumentCustomSchemaIdForClient, // GetCurrentDocumentCustomMetadataForClient) behind two GET routes: // // - GET /client/{clientId}/documents/{documentId}/schema // - GET /client/{clientId}/documents/{documentId}/all-metadata // // Plan §3 mandates the singular `/client/...` form so Permit.io's // existing client_user policy applies. The composite FK in migration // 128 (custom_schema_id, clientId) → (id, client_id) on // client_metadata_schemas guarantees the schema row's client_id // matches the document's clientId, so the M1 client-scoped queries // are sufficient for cross-client rejection without a re-check. package queryapi import ( "context" "encoding/json" "errors" "log/slog" "net/http" "queryorchestration/internal/cognitoauth" "queryorchestration/internal/database/repository" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/labstack/echo/v4" openapi_types "github.com/oapi-codegen/runtime/types" ) // dbAccessor is the minimal interface the M5 handlers need from the // controller config. The existing serviceconfig.ConfigProvider // satisfies this; declaring it here keeps the M5 surface explicit and // avoids dragging the full config interface into review. type dbAccessor interface { GetDBQueries() *repository.Queries } // GetDocumentSchema is GET /client/{clientId}/documents/{documentId}/schema. // Plan §10 M5 + plan §3 (singular routes). func (s *Controllers) GetDocumentSchema(ctx echo.Context, clientId BotClientID, documentId M5DocumentID) error { if _, ok := cognitoauth.GetUserSubject(ctx); !ok { return echo.NewHTTPError(http.StatusUnauthorized, "missing user subject") } dbCfg, ok := s.cfg.(dbAccessor) if !ok { slog.Error("M5: controller config does not expose GetDBQueries") return echo.NewHTTPError(http.StatusInternalServerError, "internal server error") } queries := dbCfg.GetDBQueries() docID := uuid.UUID(documentId) reqCtx := ctx.Request().Context() enriched, err := queries.GetDocumentEnrichedForClient(reqCtx, &repository.GetDocumentEnrichedForClientParams{ ID: docID, ClientID: clientId, }) if errors.Is(err, pgx.ErrNoRows) { return echo.NewHTTPError(http.StatusNotFound, "document not found") } if err != nil { slog.Error("M5: enriched document query failed", "error", err, "where", "schema") return echo.NewHTTPError(http.StatusInternalServerError, "document query failed") } resp := DocumentSchemaResponse{ DocumentId: openapi_types.UUID(enriched.ID), ClientId: enriched.Clientid, } if enriched.CustomSchemaID == nil { // Plan §10 M5: unbound document returns 200 with schema=null. return ctx.JSON(http.StatusOK, resp) } schemaRow, err := queries.GetClientMetadataSchema(reqCtx, *enriched.CustomSchemaID) if err != nil { // The composite FK from migration 128 makes a missing schema // row impossible in the bound case; surface as 500 for // observability. slog.Error("M5: bound schema not found", "error", err, "schema_id", *enriched.CustomSchemaID) return echo.NewHTTPError(http.StatusInternalServerError, "schema row missing") } body, err := unmarshalSchemaBody(schemaRow.SchemaDef) if err != nil { slog.Error("M5: unmarshal schema body", "error", err, "schema_id", schemaRow.ID) return echo.NewHTTPError(http.StatusInternalServerError, "schema body decode failed") } resp.Schema = &body return ctx.JSON(http.StatusOK, resp) } // GetDocumentAllMetadata is GET /client/{clientId}/documents/{documentId}/all-metadata. // Plan §10 M5: composes three M1 client-scoped reads into a single // bundle. Labels is always a non-nil array; customMetadata is null when // no schema is bound or no metadata rows exist. func (s *Controllers) GetDocumentAllMetadata(ctx echo.Context, clientId BotClientID, documentId M5DocumentID) error { if _, ok := cognitoauth.GetUserSubject(ctx); !ok { return echo.NewHTTPError(http.StatusUnauthorized, "missing user subject") } dbCfg, ok := s.cfg.(dbAccessor) if !ok { slog.Error("M5: controller config does not expose GetDBQueries") return echo.NewHTTPError(http.StatusInternalServerError, "internal server error") } queries := dbCfg.GetDBQueries() docID := uuid.UUID(documentId) reqCtx := ctx.Request().Context() enriched, err := queries.GetDocumentEnrichedForClient(reqCtx, &repository.GetDocumentEnrichedForClientParams{ ID: docID, ClientID: clientId, }) if errors.Is(err, pgx.ErrNoRows) { return echo.NewHTTPError(http.StatusNotFound, "document not found") } if err != nil { slog.Error("M5: enriched document query failed", "error", err, "where", "all-metadata") return echo.NewHTTPError(http.StatusInternalServerError, "document query failed") } labels, err := queries.GetDocumentLabelsForClient(reqCtx, &repository.GetDocumentLabelsForClientParams{ DocumentID: docID, ClientID: clientId, }) if err != nil { slog.Error("M5: load labels", "error", err, "document_id", docID) return echo.NewHTTPError(http.StatusInternalServerError, "labels query failed") } customMeta, err := loadCurrentCustomMetadata(reqCtx, queries, docID, clientId) if err != nil { slog.Error("M5: load custom metadata", "error", err, "document_id", docID) return echo.NewHTTPError(http.StatusInternalServerError, "custom metadata query failed") } resp := DocumentAllMetadataResponse{ Document: enrichedRowToResponse(enriched), Labels: labelsToResponse(labels), CustomMetadata: customMeta, } return ctx.JSON(http.StatusOK, resp) } // unmarshalSchemaBody decodes the schema_def jsonb column into a map. // The map is the wire shape DocumentSchemaResponse.Schema expects. func unmarshalSchemaBody(raw []byte) (map[string]interface{}, error) { var out map[string]interface{} if err := json.Unmarshal(raw, &out); err != nil { return nil, err } return out, nil } // loadCurrentCustomMetadata fetches the latest metadata row for a // document and returns it as a map. pgx.ErrNoRows (no metadata yet) // returns nil so the response shows customMetadata=null. The query is // client-scoped so a cross-client document also returns nil. func loadCurrentCustomMetadata(ctx context.Context, queries *repository.Queries, docID uuid.UUID, clientID string) (*map[string]interface{}, error) { row, err := queries.GetCurrentDocumentCustomMetadataForClient(ctx, &repository.GetCurrentDocumentCustomMetadataForClientParams{ DocumentID: docID, ClientID: clientID, }) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, nil } return nil, err } if len(row.Metadata) == 0 { return nil, nil } var out map[string]interface{} if err := json.Unmarshal(row.Metadata, &out); err != nil { return nil, err } return &out, nil } // enrichedRowToResponse builds the wire DocumentEnriched from the M1 // repository row. Labels is intentionally an empty slice — the M5 // all-metadata response carries labels as a top-level field; the // embedded DocumentEnriched.Labels is set to a non-nil empty slice so // the JSON wire shape stays predictable. // // Optional fields are passed through as pointer copies. For nullable // columns whose Go and wire types match (`*string`, `*int64`) the // pointer is copied directly. For UUID columns the wire type is // `*openapi_types.UUID` while sqlc emits `*uuid.UUID`; both are // declared as `[16]byte` so the unsafe pointer cast is a no-op // type-rename, equivalent to a manual `if x != nil { v := openapi_types.UUID(*x); &v }` // without the per-field branching that complicated the prior version. func enrichedRowToResponse(row *repository.GetDocumentEnrichedForClientRow) DocumentEnriched { hasCustom := row.HasCustomMetadata return DocumentEnriched{ Id: DocumentID(row.ID), ClientId: ClientID(row.Clientid), Hash: Hash(row.Hash), HasTextRecord: false, Labels: []LabelRecord{}, FolderId: (*openapi_types.UUID)(row.Folderid), Filename: row.Filename, OriginalPath: row.Originalpath, FileSizeBytes: row.FileSizeBytes, CustomSchemaId: (*openapi_types.UUID)(row.CustomSchemaID), HasCustomMetadata: &hasCustom, } } // labelsToResponse converts repository labels into the wire // BotLabelRecord shape. Returns a non-nil empty slice when no labels // exist so the JSON payload emits `[]` rather than `null`. M5 uses // BotLabelRecord (not the email-validated LabelRecord) because the // underlying documentLabels.appliedBy is varchar(255) and may carry // non-email values from upstream services. func labelsToResponse(rows []*repository.Documentlabel) []BotLabelRecord { out := make([]BotLabelRecord, 0, len(rows)) for _, r := range rows { out = append(out, BotLabelRecord{ Id: openapi_types.UUID(r.ID), DocumentId: openapi_types.UUID(r.Documentid), Label: r.Label, AppliedBy: r.Appliedby, AppliedAt: r.Appliedat.Time, }) } return out }