Files
Jay Brown 72de690894 Merged in feature/chatbot1 (pull request #223)
Chatbot functionality

* baseline working

* missing test file

* more tests
2026-05-07 20:56:18 +00:00

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
}