72de690894
Chatbot functionality * baseline working * missing test file * more tests
231 lines
8.9 KiB
Go
231 lines
8.9 KiB
Go
// 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
|
|
}
|