Source: routes/schemas.js

/**
 * This module provides code a set of schemas for params passed to
 * the nunjuck templates in `./src/views`
 */

import * as v from 'valibot'
import { MiddlewareError } from '../utils/errors.js'

const Integer = v.pipe(v.number(), v.integer())
const Url = v.pipe(v.string(), v.url())
const DateString = v.union([
  v.pipe(v.string(), v.isoDate()),
  v.pipe(v.string(), v.isoDateTime()),
  v.pipe(v.string(), v.isoTimestamp())
])

export const EmptyParams = v.object({})

export const ErrorPageParams = v.object({
  err: v.instance(MiddlewareError),
  env: v.string(),
  supportEmail: v.pipe(v.string(), v.email()),
  uptime: v.optional(v.string()),
  downtime: v.optional(v.string())
})

export const NonEmptyString = v.pipe(v.string(), v.nonEmpty())

export const Base = v.object({
  // serviceName: NonEmptyString,
  // pageTitle: NonEmptyString,
  pageName: v.optional(NonEmptyString)
})

export const StartPage = v.object({
  ...Base.entries
})

export const PaginationItem = v.variant('type', [
  v.strictObject({
    type: v.literal('number'),
    number: v.pipe(v.number(), v.integer()),
    href: v.string(),
    current: v.boolean()
  }),
  v.strictObject({
    type: v.literal('ellipsis'),
    ellipsis: v.literal(true),
    href: v.string()
  })
])

export const PaginationParams = v.optional(v.strictObject({
  previous: v.optional(v.strictObject({
    href: v.string()
  })),
  next: v.optional(v.strictObject({
    href: v.string()
  })),
  items: v.array(PaginationItem)
}))

export const dataRangeParams = v.object({
  minRow: v.pipe(v.number(), v.integer(), v.minValue(0)),
  maxRow: v.pipe(v.number(), v.integer(), v.minValue(0)),
  totalRows: v.pipe(v.number(), v.integer(), v.minValue(0)),
  maxPageNumber: v.pipe(v.number(), v.integer(), v.minValue(0)),
  pageLength: v.pipe(v.number(), v.integer(), v.minValue(1)),
  offset: v.pipe(v.number(), v.integer(), v.minValue(0))
})

export const errorSummaryParams = v.strictObject({
  heading: v.optional(v.string()),
  items: v.array(v.strictObject({
    html: v.string(),
    href: v.optional(v.string())
  }))
})

export const tableParams = v.strictObject({
  columns: v.array(NonEmptyString),
  rows: v.array(v.strictObject({
    columns: v.objectWithRest(
      {},
      v.strictObject({
        error: v.optional(v.object({
          message: v.string()
        })),
        value: v.nullish(v.string()),
        html: v.optional(v.string()),
        classes: v.optional(v.string())
      })
    )
  })),
  fields: v.array(NonEmptyString)
})

/**
 * The values of this enum should match values of the 'status' column
 * in the query in `fetchLpaOverview` middleware
 */
export const datasetStatusEnum = {
  Live: 'Live',
  'Needs improving': 'Needs improving',
  Warning: 'Warning',
  Error: 'Error',
  'Not submitted': 'Not submitted'
}

export const DeadlineNoticeField = v.strictObject({
  type: v.union([
    v.literal('due'),
    v.literal('overdue')
  ]),
  deadline: v.string()
})

const IssueSpecification = v.optional(v.looseObject({
  datasetField: NonEmptyString,
  field: NonEmptyString,
  description: v.optional(NonEmptyString),
  dataset: v.optional(NonEmptyString),
  guidance: v.optional(NonEmptyString)
}))

const OrgField = v.strictObject({ name: NonEmptyString, organisation: NonEmptyString, statistical_geography: v.optional(v.string()), entity: v.optional(Integer), dataset: v.optional(v.string()) })
const DatasetNameField = v.looseObject({ name: NonEmptyString, dataset: NonEmptyString, collection: v.string() })
const PlanningGroupProvisionsField = v.optional(v.array(v.strictObject({
  organisation: NonEmptyString,
  name: v.nullable(v.string()),
  dataset: NonEmptyString,
  project: v.nullable(v.string()),
  provision_reason: v.nullable(v.string())
})))
const DatasetItem = v.strictObject({
  endpointCount: v.optional(v.number()),
  status: v.enum(datasetStatusEnum),
  dataset: NonEmptyString,
  issueCount: v.optional(v.number()),
  error: v.optional(v.nullable(NonEmptyString)),
  issue: v.optional(NonEmptyString),
  entityCount: v.optional(v.number()),
  authority: v.optional(v.string()),
  project: v.optional(v.string()),
  // synthetic entry, represents a user friendly count (e.g. count missing value in a column as 1 issue)
  numIssues: v.optional(v.number()),
  notice: v.optional(DeadlineNoticeField),
  endpointErrorCount: v.optional(v.number())
})

const OverviewDatasets = v.object({
  statutory: v.optional(v.array(DatasetItem)),
  expected: v.optional(v.array(DatasetItem)),
  prospective: v.optional(v.array(DatasetItem)),
  other: v.optional(v.array(DatasetItem))
})

export const OrgOverviewPage = v.strictObject({
  collections: v.optional(v.array(v.strictObject({ value: NonEmptyString, text: NonEmptyString }))),
  filters: v.optional(v.strictObject({
    status: v.array(v.string()),
    collection: v.array(v.string()),
    requirement: v.array(v.string())
  })),
  filteredDatasets: v.optional(OverviewDatasets),
  resultTotal: v.optional(Integer),
  selectedFilters: v.optional(v.array(v.strictObject({ group: NonEmptyString, text: NonEmptyString, href: NonEmptyString }))),
  organisation: OrgField,
  datasets: OverviewDatasets,
  totalDatasets: Integer,
  datasetsWithEndpoints: Integer,
  datasetsWithIssues: Integer,
  datasetsWithErrors: Integer,
  isODPMember: v.boolean(),
  parentGroup: v.optional(v.nullable(v.array(v.strictObject({
    entity: Integer,
    name: NonEmptyString,
    organisation: NonEmptyString
  })))),
  planningGroupMembers: v.optional(v.nullable(v.array(v.strictObject({
    organisation: NonEmptyString,
    name: NonEmptyString
  }))))
})

export const OrgFindPage = v.strictObject({
  orgsByDataset: v.record(NonEmptyString, v.array(OrgField))
})

export const OrgGetStarted = v.strictObject({
  organisation: OrgField,
  dataset: DatasetNameField,
  authority: v.string(),
  planningGroupProvisions: PlanningGroupProvisionsField
})

export const OrgDatasetOverview = v.strictObject({
  downloadUrl: v.optional(Url),
  organisation: OrgField,
  dataset: DatasetNameField,
  showMap: v.boolean(),
  authority: v.string(),
  taskCount: Integer,
  alternateSources: v.optional(v.array(v.strictObject({ name: NonEmptyString }))),
  stats: v.strictObject({
    numberOfRecords: Integer,
    endpoints: v.array(v.strictObject({
      name: v.string(),
      documentation_url: v.nullish(v.string()),
      endpoint_url: v.string(),
      endpoint: NonEmptyString,
      lastAccessed: v.string(),
      lastUpdated: v.nullable(v.string()),
      entryDate: v.optional(v.nullable(v.string())),
      error: v.optional(v.strictObject({
        code: Integer,
        exception: v.string()
      }))
    }))
  }),
  planningGroupProvisions: PlanningGroupProvisionsField,
  parentGroup: v.optional(v.nullable(v.array(v.strictObject({
    entity: Integer,
    name: NonEmptyString,
    organisation: NonEmptyString
  })))),
  notice: v.optional(DeadlineNoticeField)
})

export const OrgDataView = v.strictObject({
  downloadUrl: v.optional(Url),
  organisation: OrgField,
  dataset: DatasetNameField,
  taskCount: Integer,
  endpointCount: Integer,
  authority: v.string(),
  tableParams,
  pagination: PaginationParams,
  dataRange: dataRangeParams,
  alternateSources: v.optional(v.array(v.strictObject({ name: NonEmptyString }))),
  planningGroupProvisions: PlanningGroupProvisionsField
})

const TaskList = v.array(v.strictObject({
  title: v.strictObject({ text: NonEmptyString }),
  href: NonEmptyString,
  status: v.strictObject({
    tag: v.strictObject({
      classes: NonEmptyString,
      text: NonEmptyString
    })
  })
}))

export const OrgDatasetTaskList = v.strictObject({
  taskList: TaskList,
  endpointTaskLists: v.optional(v.array(v.strictObject({
    endpoint: NonEmptyString,
    endpointUrl: NonEmptyString,
    endpointNumber: v.pipe(Integer, v.minValue(1)),
    taskList: TaskList
  }))),
  datasetTasks: v.optional(TaskList),
  organisation: OrgField,
  authority: v.string(),
  dataset: v.strictObject({
    dataset: NonEmptyString,
    name: NonEmptyString,
    collection: NonEmptyString
  }),
  planningGroupProvisions: PlanningGroupProvisionsField
})

export const OrgEndpointError = v.strictObject({
  organisation: OrgField,
  dataset: DatasetNameField,
  errorData: v.strictObject({
    endpoint_url: Url,
    http_status: v.optional(Integer),
    latest_log_entry_date: DateString,
    latest_200_date: v.optional(DateString)
  })
})

const MapGeometry = v.union([
  v.string(),
  v.number(),
  v.object({
    type: v.string(),
    reference: v.optional(v.string()),
    geo: v.nullish(v.string())
  })
])

const MapGeometries = v.array(MapGeometry)

export const OrgIssueTable = v.strictObject({
  taskEntryDate: v.optional(v.string()),
  endpointUrl: v.optional(v.string()),
  organisation: OrgField,
  dataset: DatasetNameField,
  errorSummary: errorSummaryParams,
  issueType: v.string(),
  issueSpecification: IssueSpecification,
  tableParams,
  pagination: PaginationParams,
  dataRange: dataRangeParams,
  geometries: v.optional(MapGeometries)
})

export const OrgIssueDetails = v.strictObject({
  taskEntryDate: v.optional(v.string()),
  endpointUrl: v.optional(v.string()),
  taskTableUrl: v.optional(v.string()),
  organisation: OrgField,
  dataset: DatasetNameField,
  errorSummary: errorSummaryParams,
  issueType: NonEmptyString,
  issueField: NonEmptyString,
  entry: v.strictObject({
    title: NonEmptyString,
    fields: v.array(v.strictObject({
      key: v.strictObject({ text: NonEmptyString }),
      value: v.strictObject({ html: v.string(), originalValue: v.optional(v.string()) }),
      classes: v.string()
    })),
    geometries: v.optional(MapGeometries)
  }),
  pagination: PaginationParams,
  pageNumber: v.pipe(v.number(), v.integer()),
  dataRange: dataRangeParams,
  issueSpecification: IssueSpecification
})

export const CheckAnswers = v.strictObject({
  options: v.strictObject({
    endpointUrl: v.optional(v.string()),
    datasetsInResource: v.optional(v.array(NonEmptyString))
  }),
  values: v.strictObject({
    lpa: NonEmptyString,
    name: NonEmptyString,
    email: v.pipe(v.string(), v.email()),
    dataset: NonEmptyString,
    'documentation-url': Url,
    hasLicence: NonEmptyString,
    errors: v.optional(v.array(v.strictObject({
      text: NonEmptyString
    })))
  })
})

export const ChooseDataset = v.strictObject({
  errors: v.strictObject({
    dataset: v.optional(v.strictObject({
      type: v.enum({
        required: 'required'
      })
    }))
  })
})

export const DatasetDetails = v.strictObject({
  organisation: OrgField,
  dataset: DatasetNameField,
  values: v.strictObject({
    dataset: NonEmptyString,
    geomType: v.optional(v.picklist(['point', 'polygon']))
  }),
  errors: v.record(NonEmptyString, v.strictObject({
    type: NonEmptyString
  }))
})

const SubmitEndpointConfirmation = v.strictObject({
  values: v.object({
    dataset: NonEmptyString,
    email: NonEmptyString,
    reference: v.optional(NonEmptyString),
    references: v.optional(v.array(NonEmptyString))
  })
})

/**
 * This acts as a registry of template -> schema for convenience.
 */
export const templateSchema = new Map([
  ['dataset-details.html', DatasetDetails],
  ['check-answers.html', CheckAnswers],
  ['choose-dataset.html', ChooseDataset],
  ['lpa-details.html', v.any()],

  ['submit/confirmation.html', SubmitEndpointConfirmation],

  ['organisations/overview.html', OrgOverviewPage],
  ['organisations/find.html', OrgFindPage],
  ['organisations/get-started.html', OrgGetStarted],
  ['organisations/dataset-overview.html', OrgDatasetOverview],
  ['organisations/dataview.html', OrgDataView],
  ['organisations/datasetTaskList.html', OrgDatasetTaskList],
  ['organisations/http-error.html', OrgEndpointError],
  ['organisations/issueTable.html', OrgIssueTable],
  ['organisations/issueDetails.html', OrgIssueDetails],

  ['errorPages/error.njk', ErrorPageParams],
  ['privacy-notice.html', EmptyParams],
  ['landing.html', EmptyParams],
  ['cookies.html', EmptyParams],
  ['accessibility.html', EmptyParams],
  ['roadmap.html', EmptyParams]
])