diff --git a/.yarnrc.yml b/.yarnrc.yml index b971fca5c..82ba9c7ff 100644 --- a/.yarnrc.yml +++ b/.yarnrc.yml @@ -4,3 +4,13 @@ npmScopes: hmcts-cft: npmRegistryServer: "https://pkgs.dev.azure.com/hmcts/Artifacts/_packaging/hmcts-lib/npm/registry/" npmAlwaysAuth: true + +supportedArchitectures: + os: + - current + - darwin + - linux + cpu: + - current + - arm64 + - x64 diff --git a/apps/postgres/prisma.config.ts b/apps/postgres/prisma.config.ts index ed90f4c4c..a19205bc6 100644 --- a/apps/postgres/prisma.config.ts +++ b/apps/postgres/prisma.config.ts @@ -4,7 +4,7 @@ import { defineConfig, env } from "prisma/config"; if (process.env.POSTGRES_HOST && process.env.POSTGRES_USER && process.env.POSTGRES_PASSWORD && process.env.POSTGRES_PORT && process.env.POSTGRES_DATABASE) { const { POSTGRES_HOST, POSTGRES_USER, POSTGRES_PASSWORD, POSTGRES_PORT, POSTGRES_DATABASE } = process.env; - process.env.DATABASE_URL = `postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DATABASE}?sslmode=require`; + process.env.DATABASE_URL = `postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DATABASE}?sslmode=require&connect_timeout=10`; } process.env.DATABASE_URL ??= "postgresql://hmcts@localhost:5433/postgres"; diff --git a/apps/postgres/prisma/migrations/20260528115459_add_third_party_push_log/migration.sql b/apps/postgres/prisma/migrations/20260528115459_add_third_party_push_log/migration.sql deleted file mode 100644 index bfafc268d..000000000 --- a/apps/postgres/prisma/migrations/20260528115459_add_third_party_push_log/migration.sql +++ /dev/null @@ -1,2 +0,0 @@ --- AddForeignKey -ALTER TABLE "artefact" ADD CONSTRAINT "artefact_list_type_id_fkey" FOREIGN KEY ("list_type_id") REFERENCES "list_types"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/apps/postgres/prisma/migrations/20260707090002/migration.sql b/apps/postgres/prisma/migrations/20260707090002/migration.sql new file mode 100644 index 000000000..8af4ff912 --- /dev/null +++ b/apps/postgres/prisma/migrations/20260707090002/migration.sql @@ -0,0 +1,10 @@ +-- AddForeignKey +DO $$ BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.table_constraints + WHERE constraint_name = 'artefact_list_type_id_fkey' + AND table_name = 'artefact' + ) THEN + ALTER TABLE "artefact" ADD CONSTRAINT "artefact_list_type_id_fkey" FOREIGN KEY ("list_type_id") REFERENCES "list_types"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + END IF; +END $$; diff --git a/apps/postgres/prisma/seed.ts b/apps/postgres/prisma/seed.ts index 4c30b2d3b..7cef3747f 100644 --- a/apps/postgres/prisma/seed.ts +++ b/apps/postgres/prisma/seed.ts @@ -40,25 +40,26 @@ async function seedReferenceData() { }); } console.log(`Seeded ${locationData.jurisdictions.length} jurisdictions`); - - console.log("Seeding sub-jurisdictions..."); - for (const subJurisdiction of locationData.subJurisdictions) { - await prisma.subJurisdiction.upsert({ - where: { subJurisdictionId: subJurisdiction.subJurisdictionId }, - create: { - subJurisdictionId: subJurisdiction.subJurisdictionId, - name: subJurisdiction.name, - welshName: subJurisdiction.welshName, - jurisdictionId: subJurisdiction.jurisdictionId - }, - update: { name: subJurisdiction.name, welshName: subJurisdiction.welshName, jurisdictionId: subJurisdiction.jurisdictionId } - }); - } - console.log(`Seeded ${locationData.subJurisdictions.length} sub-jurisdictions`); } else { console.log("Skipping location seed: tables already contain data"); } + // Always upsert sub-jurisdictions to pick up new entries + console.log("Seeding sub-jurisdictions..."); + for (const subJurisdiction of locationData.subJurisdictions) { + await prisma.subJurisdiction.upsert({ + where: { subJurisdictionId: subJurisdiction.subJurisdictionId }, + create: { + subJurisdictionId: subJurisdiction.subJurisdictionId, + name: subJurisdiction.name, + welshName: subJurisdiction.welshName, + jurisdictionId: subJurisdiction.jurisdictionId + }, + update: { name: subJurisdiction.name, welshName: subJurisdiction.welshName, jurisdictionId: subJurisdiction.jurisdictionId } + }); + } + console.log(`Seeded ${locationData.subJurisdictions.length} sub-jurisdictions`); + // Always run list type seeding to pick up new entries console.log("Seeding list types..."); const allSubJurisdictions = await prisma.subJurisdiction.findMany(); @@ -222,6 +223,7 @@ async function main() { console.log("Starting seed..."); await seedReferenceData(); await seedTestData(); + console.log("Seed completed successfully!"); } diff --git a/apps/postgres/start.sh b/apps/postgres/start.sh index 6ede5d740..34a3f03ab 100755 --- a/apps/postgres/start.sh +++ b/apps/postgres/start.sh @@ -10,13 +10,10 @@ if [ -d "/mnt/secrets" ]; then if [ -d "$vault_dir" ]; then for secret in "$vault_dir"*; do name=$(basename "$secret") - # Skip CSI driver internal entries (start with ..) case "$name" in ..*) continue ;; esac - # Check for regular file or symlink pointing to a file if [ -f "$secret" ] || [ -L "$secret" ]; then - # Verify it's not a symlink to a directory if [ ! -d "$secret" ]; then value=$(cat "$secret") export "$name"="$value" @@ -30,24 +27,22 @@ fi echo "Resolving any failed migrations..." printf "UPDATE _prisma_migrations SET rolled_back_at = NOW() WHERE finished_at IS NULL AND rolled_back_at IS NULL AND started_at IS NOT NULL;" | \ - npx prisma db execute --stdin --config=./prisma.config.ts 2>/dev/null || true + npx --ignore-scripts prisma db execute --stdin --config=./prisma.config.ts 2>/dev/null || true + +echo "Clearing stale migration records for removed migrations..." +printf "DELETE FROM _prisma_migrations WHERE migration_name IN ('20260527140208', '20260528115459_add_third_party_push_log');" | \ + npx --ignore-scripts prisma db execute --stdin --config=./prisma.config.ts 2>/dev/null || true echo "Running database migrations..." -npx prisma migrate deploy --config=./prisma.config.ts - -if [ $? -eq 0 ]; then - echo "Migrations completed successfully" - echo "Starting health proxy on port 5555..." - node health-server.mjs & - HEALTH_PID=$! - - echo "Starting Prisma Studio on port 5556..." - npx prisma studio --config=./prisma.config.ts --port 5556 --browser none & - STUDIO_PID=$! - - # Wait for both processes - wait $HEALTH_PID $STUDIO_PID -else - echo "Migration failed, exiting..." - exit 1 -fi +npx --ignore-scripts prisma migrate deploy --config=./prisma.config.ts + +echo "Migrations completed successfully" +echo "Starting health proxy on port 5555..." +node health-server.mjs & +HEALTH_PID=$! + +echo "Starting Prisma Studio on port 5556..." +npx --ignore-scripts prisma studio --config=./prisma.config.ts --port 5556 --browser none & +STUDIO_PID=$! + +wait $HEALTH_PID $STUDIO_PID diff --git a/apps/web/package.json b/apps/web/package.json index a15d33744..4a4505d31 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -46,6 +46,9 @@ "@hmcts/subscriptions": "workspace:*", "@hmcts/system-admin-pages": "workspace:*", "@hmcts/test-support": "workspace:*", + "@hmcts/upper-tribunal-administrative-appeals-chamber-daily-hearing-list": "workspace:*", + "@hmcts/upper-tribunal-lands-chamber-daily-hearing-list": "workspace:*", + "@hmcts/upper-tribunal-tax-and-chancery-chamber-daily-hearing-list": "workspace:*", "@hmcts/web-core": "workspace:*", "compression": "1.8.1", "config": "4.4.2", diff --git a/apps/web/src/app.test.ts b/apps/web/src/app.test.ts index c0efa92f3..05a4eae62 100644 --- a/apps/web/src/app.test.ts +++ b/apps/web/src/app.test.ts @@ -73,6 +73,18 @@ vi.mock("@hmcts/care-standards-tribunal-weekly-hearing-list/config", () => ({ pageRoutes: { path: "/mock/care-standards-tribunal/pages" } })); +vi.mock("@hmcts/upper-tribunal-tax-and-chancery-chamber-daily-hearing-list/config", () => ({ + moduleRoot: "/mock/utcc" +})); + +vi.mock("@hmcts/upper-tribunal-lands-chamber-daily-hearing-list/config", () => ({ + moduleRoot: "/mock/utlc" +})); + +vi.mock("@hmcts/upper-tribunal-administrative-appeals-chamber-daily-hearing-list/config", () => ({ + moduleRoot: "/mock/utaac" +})); + vi.mock("@hmcts/civil-and-family-daily-cause-list/config", () => ({ moduleRoot: "/mock/civil-family", pageRoutes: { path: "/mock/civil-family/pages" } diff --git a/apps/web/src/app.ts b/apps/web/src/app.ts index 130444386..eb5311e1a 100644 --- a/apps/web/src/app.ts +++ b/apps/web/src/app.ts @@ -37,6 +37,9 @@ import { moduleRoot as systemAdminModuleRoot, pages as systemAdminPages } from "@hmcts/system-admin-pages/config"; +import { moduleRoot as utaacModuleRoot } from "@hmcts/upper-tribunal-administrative-appeals-chamber-daily-hearing-list/config"; +import { moduleRoot as utlcModuleRoot } from "@hmcts/upper-tribunal-lands-chamber-daily-hearing-list/config"; +import { moduleRoot as utccModuleRoot } from "@hmcts/upper-tribunal-tax-and-chancery-chamber-daily-hearing-list/config"; import { moduleRoot as utiacJrModuleRoot } from "@hmcts/utiac-jr-daily-hearing-list/config"; import { moduleRoot as utiacStatutoryAppealModuleRoot } from "@hmcts/utiac-statutory-appeal-daily-hearing-list/config"; import { @@ -115,6 +118,9 @@ export async function createApp(): Promise { fttTaxChamberModuleRoot, fttLrtModuleRoot, fttRptModuleRoot, + utccModuleRoot, + utlcModuleRoot, + utaacModuleRoot, civilFamilyCauseListModuleRoot, civilDailyCauseListModuleRoot, familyDailyCauseListModuleRoot, diff --git a/apps/web/src/pages/(admin)/non-strategic-upload-summary/cy.ts b/apps/web/src/pages/(admin)/non-strategic-upload-summary/cy.ts index bdf0399c5..3d9490b4d 100644 --- a/apps/web/src/pages/(admin)/non-strategic-upload-summary/cy.ts +++ b/apps/web/src/pages/(admin)/non-strategic-upload-summary/cy.ts @@ -1,14 +1,14 @@ export const cy = { - pageTitle: "[Welsh] Non-strategic upload - File upload summary - Court and tribunal hearings - GOV.UK", - heading: "[Welsh] File upload summary", - subHeading: "[Welsh] Check upload details", - courtName: "[Welsh] Court name", - file: "[Welsh] File", - listType: "[Welsh] List type", - hearingStartDate: "[Welsh] Hearing start date", - sensitivity: "[Welsh] Sensitivity", - language: "[Welsh] Language", - displayFileDates: "[Welsh] Display file dates", - change: "[Welsh] Change", - confirmButton: "[Welsh] Confirm" + pageTitle: "Llwytho i fyny â llaw - Crynodeb lanlwytho ffeil - Gwrandawiadau llys a thribiwnlys - GOV.UK", + heading: "Crynodeb lanlwytho ffeil", + subHeading: "Gwirio manylion lanlwytho", + courtName: "Enw'r llys", + file: "Ffeil", + listType: "Math o restr", + hearingStartDate: "Dyddiad cychwyn y gwrandawiad", + sensitivity: "Sensitifrwydd", + language: "Iaith", + displayFileDates: "Dangos dyddiadau ffeil", + change: "Newid", + confirmButton: "Cadarnhau" }; diff --git a/apps/web/src/pages/(admin)/non-strategic-upload/cy.ts b/apps/web/src/pages/(admin)/non-strategic-upload/cy.ts index 0005479e2..14b689875 100644 --- a/apps/web/src/pages/(admin)/non-strategic-upload/cy.ts +++ b/apps/web/src/pages/(admin)/non-strategic-upload/cy.ts @@ -1,60 +1,60 @@ export const cy = { - title: "[Welsh] Upload Excel file", - pageTitle: "[Welsh] Upload - Upload Excel file", - warningTitle: "[Welsh] Warning", - warningMessage: - "[Welsh] Prior to upload you must ensure the file is suitable for publication e.g. redaction of personal data has been done during the production of this file.", - fileUploadLabel: "[Welsh] Manually upload an Excel file (.xlsx), max size 2MB", - courtLabel: "[Welsh] Court name or Tribunal name", - listTypeLabel: "[Welsh] List type", - listTypePlaceholder: "[Welsh] Please choose a list type", - hearingStartDateLabel: "[Welsh] Hearing start date", - hearingStartDateHint: "[Welsh] For example, 16 01 2022", - sensitivityLabel: "[Welsh] Sensitivity", - languageLabel: "[Welsh] Language", - displayFromLabel: "[Welsh] Display file from", - displayFromHint: "[Welsh] For example, 27 01 2022", - displayToLabel: "[Welsh] Display file to", - displayToHint: "[Welsh] For example, 18 02 2022", - continueButton: "[Welsh] Continue", - errorSummaryTitle: "[Welsh] There is a problem", - pageHelpTitle: "[Welsh] Page help", - pageHelpLists: "[Welsh] Lists", + title: "Llwytho â llaw", + pageTitle: "Llwytho - Llwytho â llaw", + warningTitle: "Rhybudd", + warningMessage: "Cyn llwytho rhaid i chi sicrhau bod y ffeil yn addas i'w chyhoeddi e.e. bod data personol wedi'i olygu yn ystod cynhyrchu'r ffeil hon.", + fileUploadLabel: "Llwytho ffeil Excel (.xlsx) â llaw, uchafswm maint 2MB", + courtLabel: "Enw'r llys neu enw'r tribiwnlys", + listTypeLabel: "Math o restr", + listTypePlaceholder: "Dewiswch fath o restr", + hearingStartDateLabel: "Dyddiad dechrau'r gwrandawiad", + hearingStartDateHint: "Er enghraifft, 16 01 2022", + sensitivityLabel: "Sensitifrwydd", + languageLabel: "Iaith", + displayFromLabel: "Dangos y ffeil o", + displayFromHint: "Er enghraifft, 27 01 2022", + displayToLabel: "Dangos y ffeil tan", + displayToHint: "Er enghraifft, 18 02 2022", + continueButton: "Parhau", + errorSummaryTitle: "Mae yna broblem", + pageHelpTitle: "Cymorth tudalen", + pageHelpLists: "Rhestrau", pageHelpListsText: - "[Welsh] You must ensure that you only upload one file that contains all hearings for the Court or Tribunal name and List type. This should include all judges and court rooms in one document.", - pageHelpSensitivity: "[Welsh] Sensitivity", - pageHelpSensitivityText: "[Welsh] You need to indicate which user group your document should be available to:", - pageHelpSensitivityPublic: "[Welsh] Public", - pageHelpSensitivityPublicText: "[Welsh] Publication available to all users.", - pageHelpSensitivityPrivate: "[Welsh] Private", - pageHelpSensitivityPrivateText: "[Welsh] Publication available to all verified users e.g. Legal professionals and media.", - pageHelpSensitivityClassified: "[Welsh] Classified", + "Rhaid i chi sicrhau eich bod chi ond yn llwytho un ffeil sy'n cynnwys yr holl wrandawiadau ar gyfer enw'r Llys neu'r Tribiwnlys a'r math o restr. Dylai hyn gynnwys pob barnwr ac ystafell lys mewn un ddogfen.", + pageHelpSensitivity: "Sensitifrwydd", + pageHelpSensitivityText: "Mae angen i chi nodi pa grŵp defnyddwyr y dylai eich dogfen fod ar gael iddynt:", + pageHelpSensitivityPublic: "Cyhoeddus", + pageHelpSensitivityPublicText: "Cyhoeddiad ar gael i bob defnyddiwr.", + pageHelpSensitivityPrivate: "Preifat", + pageHelpSensitivityPrivateText: "Cyhoeddiad ar gael i bob defnyddiwr wedi'u dilysu e.e. Gweithwyr proffesiynol cyfreithiol a'r cyfryngau.", + pageHelpSensitivityClassified: "Dosbarthedig", pageHelpSensitivityClassifiedText: - "[Welsh] Publication only available to verified users who are in a group eligible to view that list e.g. SJP press list available to Media.", - pageHelpDisplayFrom: "[Welsh] Display from", - pageHelpDisplayFromText: "[Welsh] This will be the date the publication is available from … displayed immediately if today's date is used.", - pageHelpDisplayTo: "[Welsh] Display to", - pageHelpDisplayToText: "[Welsh] This will be the last date the publication is available. It will be displayed until 23:59 of that date.", - dayLabel: "[Welsh] Day", - monthLabel: "[Welsh] Month", - yearLabel: "[Welsh] Year", - backToTop: "[Welsh] Back to top", + "Cyhoeddiad ar gael i ddefnyddwyr wedi'u dilysu sydd mewn grŵp sy'n gymwys i weld y rhestr honno e.e. Rhestr y wasg SJP ar gael i'r Cyfryngau", + pageHelpDisplayFrom: "Dangos o", + pageHelpDisplayFromText: + "Hwn fydd y dyddiad y bydd y cyhoeddiad ar gael ohono, os defnyddir dyddiad heddiw bydd yn cael ei ddangos ar unwaith. Os defnyddir dyddiad yn y dyfodol, bydd yn cael ei ddangos o 00:01 ar y dyddiad hwnnw.", + pageHelpDisplayTo: "Dangos tan", + pageHelpDisplayToText: "Hwn fydd y dyddiad olaf y bydd y cyhoeddiad ar gael. Bydd yn cael ei ddangos tan 23:59 ar y dyddiad hwnnw.", + dayLabel: "Diwrnod", + monthLabel: "Mis", + yearLabel: "Blwyddyn", + backToTop: "Nôl i'r brig", errorMessages: { - fileRequired: "[Welsh] Please provide a file", - fileType: "[Welsh] The selected file type is not supported", - fileSize: "[Welsh] The selected file must be smaller than 2MB", - courtRequired: "[Welsh] Please enter and select a valid court", - courtTooShort: "[Welsh] Court name must be three characters or more", - listTypeRequired: "[Welsh] Please select a list type", - hearingStartDateRequired: "[Welsh] Please enter a valid hearing start date", - hearingStartDateInvalid: "[Welsh] Please enter a valid hearing start date", - sensitivityRequired: "[Welsh] Please select a sensitivity", - languageRequired: "[Welsh] Select a language option", - displayFromRequired: "[Welsh] Please enter a valid display file from date", - displayFromInvalid: "[Welsh] Please enter a valid display file from date", - displayToRequired: "[Welsh] Please enter a valid display file to date", - displayToInvalid: "[Welsh] Please enter a valid display file to date", - displayToBeforeFrom: "[Welsh] 'Display to' date must be the same as or later than 'Display from' date" + fileRequired: "Darparwch ffeil", + fileType: "Llwythwch fformat ffeil dilys", + fileSize: "Ffeil yn rhy fawr, llwythwch ffeil yn llai na 2MB", + courtRequired: "Rhowch a dewiswch lys dilys", + courtTooShort: "Rhaid i enw'r llys fod yn dri chymeriad neu fwy", + listTypeRequired: "Dewiswch fath o restr", + hearingStartDateRequired: "Rhowch ddyddiad dechrau gwrandawiad dilys", + hearingStartDateInvalid: "Rhowch ddyddiad dechrau gwrandawiad dilys", + sensitivityRequired: "Dewiswch lefel sensitifrwydd", + languageRequired: "Dewiswch iaith", + displayFromRequired: "Rhowch ddyddiad dangos y ffeil o dilys", + displayFromInvalid: "Rhowch ddyddiad dangos y ffeil o dilys", + displayToRequired: "Rhowch ddyddiad dangos y ffeil tan dilys", + displayToInvalid: "Rhowch ddyddiad dangos y ffeil tan dilys", + displayToBeforeFrom: "Rhaid i'r dyddiad dangos tan fod ar ôl y dyddiad dangos o" } }; diff --git a/apps/web/src/pages/(admin)/non-strategic-upload/index.njk b/apps/web/src/pages/(admin)/non-strategic-upload/index.njk index 0527ceb3f..acb45178b 100644 --- a/apps/web/src/pages/(admin)/non-strategic-upload/index.njk +++ b/apps/web/src/pages/(admin)/non-strategic-upload/index.njk @@ -43,7 +43,7 @@

{{ title }}

-
+ {% set fileErrorText = getError(errors, "#file") %}
diff --git a/apps/web/src/pages/(admin)/non-strategic-upload/index.ts b/apps/web/src/pages/(admin)/non-strategic-upload/index.ts index fdcf50b35..3892bb4f7 100644 --- a/apps/web/src/pages/(admin)/non-strategic-upload/index.ts +++ b/apps/web/src/pages/(admin)/non-strategic-upload/index.ts @@ -3,23 +3,30 @@ import "@hmcts/care-standards-tribunal-weekly-hearing-list"; // Register CST con import "@hmcts/sscs-daily-hearing-list"; // Register SSCS converters (28-35) import "@hmcts/cic-weekly-hearing-list"; // Register CIC converter import "@hmcts/send-daily-hearing-list"; // Register SEND converter +import "@hmcts/upper-tribunal-tax-and-chancery-chamber-daily-hearing-list"; // Register UTCC converter +import "@hmcts/upper-tribunal-lands-chamber-daily-hearing-list"; // Register UTLC converter +import "@hmcts/upper-tribunal-administrative-appeals-chamber-daily-hearing-list"; // Register UTAAC converter import { LANGUAGE_LABELS, SENSITIVITY_LABELS, storeNonStrategicUpload, type UploadFormData, validateNonStrategicUploadForm } from "@hmcts/admin-pages"; import { requireRole, USER_ROLES } from "@hmcts/auth"; import { getAllLocations, getLocationById } from "@hmcts/location"; -import { Language } from "@hmcts/publication"; +import { Language, Sensitivity } from "@hmcts/publication"; import { findListTypeById, findNonStrategicListTypes } from "@hmcts/system-admin-pages"; import { saveSession } from "@hmcts/web-core"; import type { Request, RequestHandler, Response } from "express"; import { cy } from "./cy.js"; import { en } from "./en.js"; -async function getListTypes() { +async function getListTypesData() { const nonStrategicListTypes = await findNonStrategicListTypes(); - const options = nonStrategicListTypes.map((listType) => ({ - value: listType.id.toString(), - text: listType.shortenedFriendlyName || listType.friendlyName || listType.name - })); - return [{ value: "", text: "" }, ...options]; + const options = [ + { value: "", text: "" }, + ...nonStrategicListTypes.map((listType) => ({ + value: listType.id.toString(), + text: listType.shortenedFriendlyName || listType.friendlyName || listType.name + })) + ]; + const sensitivityMap = Object.fromEntries(nonStrategicListTypes.map((listType) => [listType.id.toString(), Sensitivity.PUBLIC])); + return { options, sensitivityMap }; } const SENSITIVITY_OPTIONS = [ @@ -54,7 +61,7 @@ function transformDateFields(body: any): UploadFormData { }; } -function selectOption(options: any[], selectedValue: string | undefined) { +function selectOption(options: { value: string; text: string }[], selectedValue: string | undefined) { return options.map((item) => ({ ...item, selected: item.value === selectedValue })); } @@ -87,16 +94,17 @@ const getHandler = async (req: Request, res: Response) => { const location = locationId && !Number.isNaN(locationId) ? await getLocationById(locationId) : null; const locationName = location?.name || formData.locationName || ""; - const listTypes = await getListTypes(); + const [{ options: listTypeOptions, sensitivityMap }, locations] = await Promise.all([getListTypesData(), getAllLocations(locale)]); res.render("non-strategic-upload/index", { ...t, errors: errors.length > 0 ? errors : undefined, data: { ...formData, locationName }, - locations: await getAllLocations(locale), - listTypes: selectOption(listTypes, formData.listType), + locations, + listTypes: selectOption(listTypeOptions, formData.listType), sensitivityOptions: selectOption(SENSITIVITY_OPTIONS, formData.sensitivity), languageOptions: selectOption(LANGUAGE_OPTIONS, formData.language || Language.ENGLISH), + listTypeSensitivityMap: JSON.stringify(sensitivityMap), locale }); }; diff --git a/apps/web/src/pages/(list-types)/upper-tribunal-administrative-appeals-chamber-daily-hearing-list/index.test.ts b/apps/web/src/pages/(list-types)/upper-tribunal-administrative-appeals-chamber-daily-hearing-list/index.test.ts new file mode 100644 index 000000000..3e1c3ef89 --- /dev/null +++ b/apps/web/src/pages/(list-types)/upper-tribunal-administrative-appeals-chamber-daily-hearing-list/index.test.ts @@ -0,0 +1,311 @@ +import type { Request, Response } from "express"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockValidate = vi.hoisted(() => vi.fn()); + +vi.mock("@hmcts/list-types-common", () => ({ + createJsonValidator: () => mockValidate +})); + +vi.mock("@hmcts/publication", () => ({ + getArtefactById: vi.fn(), + getPublicationJson: vi.fn(), + PROVENANCE_LABELS: { + MANUAL_UPLOAD: "Manual Upload", + SNL: "ListAssist" + } +})); + +vi.mock("@hmcts/upper-tribunal-administrative-appeals-chamber-daily-hearing-list", () => ({ + upperTribunalAdministrativeAppealsChamberDailyHearingListEn: { + pageTitle: "Upper Tribunal (Administrative Appeals Chamber) Daily Hearing list", + provenanceLabels: { MANUAL_UPLOAD: "Manual Upload" } + }, + upperTribunalAdministrativeAppealsChamberDailyHearingListCy: { + pageTitle: "Upper Tribunal (Administrative Appeals Chamber) Daily Hearing list", + provenanceLabels: { MANUAL_UPLOAD: "Manual Upload" } + }, + renderUtaacDailyHearingListData: vi.fn() +})); + +vi.mock("@hmcts/upper-tribunal-administrative-appeals-chamber-daily-hearing-list/config", () => ({ + moduleRoot: "/mock/utaac", + schemaPath: "/mock/utaac/schema.json" +})); + +import { getArtefactById, getPublicationJson } from "@hmcts/publication"; +import { renderUtaacDailyHearingListData } from "@hmcts/upper-tribunal-administrative-appeals-chamber-daily-hearing-list"; +import { GET } from "./index.js"; + +describe("Upper Tribunal Administrative Appeals Chamber page controller", () => { + let req: Partial; + let res: Partial; + + beforeEach(() => { + vi.clearAllMocks(); + + req = { + query: {} + }; + + res = { + status: vi.fn().mockReturnThis(), + render: vi.fn(), + locals: { locale: "en" } + }; + }); + + describe("GET handler", () => { + it("should render the list successfully with valid data", async () => { + // Arrange + const mockArtefact = { + artefactId: "test-artefact-123", + locationId: "9001", + listTypeId: 999, + contentDate: new Date("2026-01-15"), + displayFrom: new Date("2026-01-15"), + displayTo: new Date("2026-01-15"), + lastReceivedDate: new Date("2026-01-15T12:00:00Z"), + provenance: "MANUAL_UPLOAD" + }; + + const mockJsonData = [ + { + time: "10:00am", + appellant: "Mr Smith", + caseReferenceNumber: "UTAAC/2026/0001", + judges: "Judge Smith", + members: "Member Jones", + modeOfHearing: "CVP", + venue: "London", + additionalInformation: "" + } + ]; + + const mockRenderedData = { + header: { + listTitle: "Upper Tribunal (Administrative Appeals Chamber) Daily Hearing list", + hearingDate: "15 January 2026", + lastUpdatedDate: "15 January 2026", + lastUpdatedTime: "12pm" + }, + hearings: mockJsonData + }; + + req.query = { artefactId: "test-artefact-123" }; + vi.mocked(getArtefactById).mockResolvedValue(mockArtefact as any); + vi.mocked(getPublicationJson).mockResolvedValue(mockJsonData); + mockValidate.mockReturnValue({ isValid: true, errors: [] }); + vi.mocked(renderUtaacDailyHearingListData).mockReturnValue(mockRenderedData); + + // Act + await GET(req as Request, res as Response); + + // Assert + expect(getArtefactById).toHaveBeenCalledWith("test-artefact-123"); + expect(getPublicationJson).toHaveBeenCalledWith("test-artefact-123"); + expect(renderUtaacDailyHearingListData).toHaveBeenCalledWith(mockJsonData, { + locale: "en", + courtName: "Upper Tribunal (Administrative Appeals Chamber)", + contentDate: mockArtefact.contentDate, + lastReceivedDate: mockArtefact.lastReceivedDate.toISOString(), + listTitle: "Upper Tribunal (Administrative Appeals Chamber) Daily Hearing list" + }); + const renderCall = vi.mocked(res.render).mock.calls[0]; + expect(renderCall[0]).toBe("upper-tribunal-administrative-appeals-chamber-daily-hearing-list"); + expect(renderCall[1]).toMatchObject({ + header: mockRenderedData.header, + hearings: mockRenderedData.hearings, + dataSource: "Manual Upload" + }); + }); + + it("should return 400 when artefactId is missing", async () => { + // Arrange + req.query = {}; + + // Act + await GET(req as Request, res as Response); + + // Assert + expect(res.status).toHaveBeenCalledWith(400); + expect(res.render).toHaveBeenCalledWith( + "errors/common", + expect.objectContaining({ + errorTitle: "Bad Request", + errorMessage: "Missing artefactId parameter" + }) + ); + }); + + it("should return 404 when artefact is not found", async () => { + // Arrange + req.query = { artefactId: "non-existent-artefact" }; + vi.mocked(getArtefactById).mockResolvedValue(null); + + // Act + await GET(req as Request, res as Response); + + // Assert + expect(res.status).toHaveBeenCalledWith(404); + expect(res.render).toHaveBeenCalledWith( + "errors/common", + expect.objectContaining({ + errorTitle: "Not Found", + errorMessage: "The requested list could not be found" + }) + ); + }); + + it("should return 404 when JSON blob is not found", async () => { + // Arrange + const mockArtefact = { + artefactId: "test-artefact-123", + locationId: "9001", + listTypeId: 999, + contentDate: new Date("2026-01-15"), + displayFrom: new Date("2026-01-15"), + displayTo: new Date("2026-01-15"), + lastReceivedDate: new Date("2026-01-15T12:00:00Z"), + provenance: "MANUAL_UPLOAD" + }; + + req.query = { artefactId: "test-artefact-123" }; + vi.mocked(getArtefactById).mockResolvedValue(mockArtefact as any); + vi.mocked(getPublicationJson).mockResolvedValue(null); + + // Act + await GET(req as Request, res as Response); + + // Assert + expect(res.status).toHaveBeenCalledWith(404); + expect(res.render).toHaveBeenCalledWith( + "errors/common", + expect.objectContaining({ + errorTitle: "Not Found", + errorMessage: "The requested list could not be found" + }) + ); + }); + + it("should return 400 when JSON validation fails", async () => { + // Arrange + const mockArtefact = { + artefactId: "test-artefact-123", + locationId: "9001", + listTypeId: 999, + contentDate: new Date("2026-01-15"), + displayFrom: new Date("2026-01-15"), + displayTo: new Date("2026-01-15"), + lastReceivedDate: new Date("2026-01-15T12:00:00Z"), + provenance: "MANUAL_UPLOAD" + }; + + req.query = { artefactId: "test-artefact-123" }; + vi.mocked(getArtefactById).mockResolvedValue(mockArtefact as any); + vi.mocked(getPublicationJson).mockResolvedValue([{ time: "10:00am" }]); + mockValidate.mockReturnValue({ isValid: false, errors: ["Missing required field: caseReferenceNumber"] }); + + // Act + await GET(req as Request, res as Response); + + // Assert + expect(res.status).toHaveBeenCalledWith(400); + expect(res.render).toHaveBeenCalledWith( + "errors/common", + expect.objectContaining({ + errorTitle: "Invalid Data", + errorMessage: "The list data is invalid" + }) + ); + }); + + it("should return 500 on server error", async () => { + // Arrange + req.query = { artefactId: "test-artefact-123" }; + vi.mocked(getArtefactById).mockRejectedValue(new Error("Database connection failed")); + + // Act + await GET(req as Request, res as Response); + + // Assert + expect(res.status).toHaveBeenCalledWith(500); + expect(res.render).toHaveBeenCalledWith( + "errors/common", + expect.objectContaining({ + errorTitle: "Error", + errorMessage: "An error occurred while displaying the list" + }) + ); + }); + + it("should use Welsh locale when specified", async () => { + // Arrange + const mockArtefact = { + artefactId: "test-artefact-123", + locationId: "9001", + listTypeId: 999, + contentDate: new Date("2026-01-15"), + displayFrom: new Date("2026-01-15"), + displayTo: new Date("2026-01-15"), + lastReceivedDate: new Date("2026-01-15T12:00:00Z"), + provenance: "MANUAL_UPLOAD" + }; + + const mockRenderedData = { + header: { + listTitle: "Welsh title", + hearingDate: "15 January 2026", + lastUpdatedDate: "15 January 2026", + lastUpdatedTime: "12pm" + }, + hearings: [] + }; + + req.query = { artefactId: "test-artefact-123" }; + res.locals = { locale: "cy" }; + vi.mocked(getArtefactById).mockResolvedValue(mockArtefact as any); + vi.mocked(getPublicationJson).mockResolvedValue([]); + mockValidate.mockReturnValue({ isValid: true, errors: [] }); + vi.mocked(renderUtaacDailyHearingListData).mockReturnValue(mockRenderedData); + + // Act + await GET(req as Request, res as Response); + + // Assert + expect(renderUtaacDailyHearingListData).toHaveBeenCalledWith([], expect.objectContaining({ locale: "cy" })); + }); + + it("should use raw provenance when not in PROVENANCE_LABELS", async () => { + // Arrange + const mockArtefact = { + artefactId: "test-artefact-123", + locationId: "9001", + listTypeId: 999, + contentDate: new Date("2026-01-15"), + displayFrom: new Date("2026-01-15"), + displayTo: new Date("2026-01-15"), + lastReceivedDate: new Date("2026-01-15T12:00:00Z"), + provenance: "UNKNOWN_SOURCE" + }; + + const mockRenderedData = { + header: { listTitle: "Title", hearingDate: "15 January 2026", lastUpdatedDate: "15 January 2026", lastUpdatedTime: "12pm" }, + hearings: [] + }; + + req.query = { artefactId: "test-artefact-123" }; + vi.mocked(getArtefactById).mockResolvedValue(mockArtefact as any); + vi.mocked(getPublicationJson).mockResolvedValue([]); + mockValidate.mockReturnValue({ isValid: true, errors: [] }); + vi.mocked(renderUtaacDailyHearingListData).mockReturnValue(mockRenderedData); + + // Act + await GET(req as Request, res as Response); + + // Assert + const renderCall = vi.mocked(res.render).mock.calls[0]; + expect(renderCall[1]).toMatchObject({ dataSource: "UNKNOWN_SOURCE" }); + }); + }); +}); diff --git a/apps/web/src/pages/(list-types)/upper-tribunal-administrative-appeals-chamber-daily-hearing-list/index.ts b/apps/web/src/pages/(list-types)/upper-tribunal-administrative-appeals-chamber-daily-hearing-list/index.ts new file mode 100644 index 000000000..1ae99c4ba --- /dev/null +++ b/apps/web/src/pages/(list-types)/upper-tribunal-administrative-appeals-chamber-daily-hearing-list/index.ts @@ -0,0 +1,40 @@ +import { createJsonValidator } from "@hmcts/list-types-common"; +import { PROVENANCE_LABELS } from "@hmcts/publication"; +import { + upperTribunalAdministrativeAppealsChamberDailyHearingListCy as cy, + upperTribunalAdministrativeAppealsChamberDailyHearingListEn as en, + renderUtaacDailyHearingListData, + type UtaacHearingList +} from "@hmcts/upper-tribunal-administrative-appeals-chamber-daily-hearing-list"; +import { schemaPath } from "@hmcts/upper-tribunal-administrative-appeals-chamber-daily-hearing-list/config"; +import { createSimpleListTypeHandler } from "../list-type-handler.js"; + +const validate = createJsonValidator(schemaPath); + +export const GET = createSimpleListTypeHandler({ + en, + cy, + validate, + logPrefix: "upper-tribunal-administrative-appeals-chamber-daily-hearing-list", + render: ({ artefact, jsonData, locale, res }) => { + const t = locale === "cy" ? cy : en; + const { header, hearings } = renderUtaacDailyHearingListData(jsonData, { + locale, + courtName: "Upper Tribunal (Administrative Appeals Chamber)", + contentDate: artefact.contentDate, + lastReceivedDate: artefact.lastReceivedDate.toISOString(), + listTitle: t.pageTitle + }); + const dataSource = PROVENANCE_LABELS[artefact.provenance] || artefact.provenance; + res.render("upper-tribunal-administrative-appeals-chamber-daily-hearing-list", { + en, + cy, + t, + title: header.listTitle, + header, + hearings, + dataSource, + pdfDownloadUrl: `/api/pdf/${artefact.artefactId}/download` + }); + } +}); diff --git a/apps/web/src/pages/(list-types)/upper-tribunal-administrative-appeals-chamber-daily-hearing-list/upper-tribunal-administrative-appeals-chamber-daily-hearing-list.njk b/apps/web/src/pages/(list-types)/upper-tribunal-administrative-appeals-chamber-daily-hearing-list/upper-tribunal-administrative-appeals-chamber-daily-hearing-list.njk new file mode 100644 index 000000000..4bb5d8ace --- /dev/null +++ b/apps/web/src/pages/(list-types)/upper-tribunal-administrative-appeals-chamber-daily-hearing-list/upper-tribunal-administrative-appeals-chamber-daily-hearing-list.njk @@ -0,0 +1,94 @@ +{% extends "layouts/base-template.njk" %} + +{% block head %} + {{ super() }} + +{% endblock %} + +{% block page_content %} +
+
+ +

{{ header.listTitle }}

+ +

+ {{ t.factLinkText }} {{ t.factAdditionalText }} +

+ +

{{ t.listForDate }} {{ header.hearingDate }}

+

{{ t.lastUpdated }} {{ header.lastUpdatedDate }} {{ t.at }} {{ header.lastUpdatedTime }}

+ +
+ + + {{ t.openingStatementTitle }} + + +
+

{{ t.openingStatement.detailsTitle }}

+

{{ t.openingStatement.listChangeNotice }}

+ +

{{ t.openingStatement.englandAndWalesTitle }}

+

{{ t.openingStatement.englandAndWalesRemoteText }}

+

{{ t.openingStatement.englandAndWalesCvpText }}

+

{{ t.openingStatement.englandAndWalesAccessText }}

+

{{ t.openingStatement.englandAndWalesContactText }}

+ +

{{ t.openingStatement.scotlandTitle }}

+

{{ t.openingStatement.scotlandRemoteText }}

+

{{ t.openingStatement.scotlandCvpText }}

+
+
+ +
+

{{ t.searchCasesTitle }}

+ + +
+ +
+ + + + + + + + + + + + + + + {% for hearing in hearings %} + + + + + + + + + + + {% endfor %} + +
{{ t.tableHeaders.time }}{{ t.tableHeaders.appellant }}{{ t.tableHeaders.caseReferenceNumber }}{{ t.tableHeaders.judges }}{{ t.tableHeaders.members }}{{ t.tableHeaders.modeOfHearing }}{{ t.tableHeaders.venue }}{{ t.tableHeaders.additionalInformation }}
{{ hearing.time }}{{ hearing.appellant }}{{ hearing.caseReferenceNumber }}{{ hearing.judges }}{{ hearing.members }}{{ hearing.modeOfHearing }}{{ hearing.venue }}{{ hearing.additionalInformation }}
+
+ +

{{ t.dataSource }}: {{ dataSource }}

+ + + +
+
+{% endblock %} diff --git a/apps/web/src/pages/(list-types)/upper-tribunal-lands-chamber-daily-hearing-list/index.test.ts b/apps/web/src/pages/(list-types)/upper-tribunal-lands-chamber-daily-hearing-list/index.test.ts new file mode 100644 index 000000000..1862c8eef --- /dev/null +++ b/apps/web/src/pages/(list-types)/upper-tribunal-lands-chamber-daily-hearing-list/index.test.ts @@ -0,0 +1,312 @@ +import type { Request, Response } from "express"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockValidate = vi.hoisted(() => vi.fn()); + +vi.mock("@hmcts/list-types-common", () => ({ + createJsonValidator: () => mockValidate +})); + +vi.mock("@hmcts/publication", () => ({ + getArtefactById: vi.fn(), + getPublicationJson: vi.fn(), + PROVENANCE_LABELS: { + MANUAL_UPLOAD: "Manual Upload", + SNL: "ListAssist" + } +})); + +vi.mock("@hmcts/upper-tribunal-lands-chamber-daily-hearing-list", () => ({ + upperTribunalLandsChamberDailyHearingListEn: { + pageTitle: "Upper Tribunal (Lands Chamber) Daily Hearing list", + provenanceLabels: { MANUAL_UPLOAD: "Manual Upload" } + }, + upperTribunalLandsChamberDailyHearingListCy: { + pageTitle: "Upper Tribunal (Lands Chamber) Daily Hearing list", + provenanceLabels: { MANUAL_UPLOAD: "Manual Upload" } + }, + renderUtlcDailyHearingListData: vi.fn() +})); + +vi.mock("@hmcts/upper-tribunal-lands-chamber-daily-hearing-list/config", () => ({ + moduleRoot: "/mock/utlc", + schemaPath: "/mock/utlc/schema.json" +})); + +import { getArtefactById, getPublicationJson } from "@hmcts/publication"; +import { renderUtlcDailyHearingListData } from "@hmcts/upper-tribunal-lands-chamber-daily-hearing-list"; +import { GET } from "./index.js"; + +describe("Upper Tribunal Lands Chamber page controller", () => { + let req: Partial; + let res: Partial; + + beforeEach(() => { + vi.clearAllMocks(); + + req = { + query: {} + }; + + res = { + status: vi.fn().mockReturnThis(), + render: vi.fn(), + locals: { locale: "en" } + }; + }); + + describe("GET handler", () => { + it("should render the list successfully with valid data", async () => { + // Arrange + const mockArtefact = { + artefactId: "test-artefact-123", + locationId: "9001", + listTypeId: 999, + contentDate: new Date("2026-01-15"), + displayFrom: new Date("2026-01-15"), + displayTo: new Date("2026-01-15"), + lastReceivedDate: new Date("2026-01-15T12:00:00Z"), + provenance: "MANUAL_UPLOAD" + }; + + const mockJsonData = [ + { + time: "10:00am", + caseReferenceNumber: "UTLC/2026/0001", + caseName: "Jones v Secretary of State", + judges: "Judge Smith", + members: "Member Jones", + hearingType: "Substantive hearing", + venue: "Royal Courts of Justice", + modeOfHearing: "In Person", + additionalInformation: "" + } + ]; + + const mockRenderedData = { + header: { + listTitle: "Upper Tribunal (Lands Chamber) Daily Hearing list", + hearingDate: "15 January 2026", + lastUpdatedDate: "15 January 2026", + lastUpdatedTime: "12pm" + }, + hearings: mockJsonData + }; + + req.query = { artefactId: "test-artefact-123" }; + vi.mocked(getArtefactById).mockResolvedValue(mockArtefact as any); + vi.mocked(getPublicationJson).mockResolvedValue(mockJsonData); + mockValidate.mockReturnValue({ isValid: true, errors: [] }); + vi.mocked(renderUtlcDailyHearingListData).mockReturnValue(mockRenderedData); + + // Act + await GET(req as Request, res as Response); + + // Assert + expect(getArtefactById).toHaveBeenCalledWith("test-artefact-123"); + expect(getPublicationJson).toHaveBeenCalledWith("test-artefact-123"); + expect(renderUtlcDailyHearingListData).toHaveBeenCalledWith(mockJsonData, { + locale: "en", + courtName: "Upper Tribunal (Lands Chamber)", + contentDate: mockArtefact.contentDate, + lastReceivedDate: mockArtefact.lastReceivedDate.toISOString(), + listTitle: "Upper Tribunal (Lands Chamber) Daily Hearing list" + }); + const renderCall = vi.mocked(res.render).mock.calls[0]; + expect(renderCall[0]).toBe("upper-tribunal-lands-chamber-daily-hearing-list"); + expect(renderCall[1]).toMatchObject({ + header: mockRenderedData.header, + hearings: mockRenderedData.hearings, + dataSource: "Manual Upload" + }); + }); + + it("should return 400 when artefactId is missing", async () => { + // Arrange + req.query = {}; + + // Act + await GET(req as Request, res as Response); + + // Assert + expect(res.status).toHaveBeenCalledWith(400); + expect(res.render).toHaveBeenCalledWith( + "errors/common", + expect.objectContaining({ + errorTitle: "Bad Request", + errorMessage: "Missing artefactId parameter" + }) + ); + }); + + it("should return 404 when artefact is not found", async () => { + // Arrange + req.query = { artefactId: "non-existent-artefact" }; + vi.mocked(getArtefactById).mockResolvedValue(null); + + // Act + await GET(req as Request, res as Response); + + // Assert + expect(res.status).toHaveBeenCalledWith(404); + expect(res.render).toHaveBeenCalledWith( + "errors/common", + expect.objectContaining({ + errorTitle: "Not Found", + errorMessage: "The requested list could not be found" + }) + ); + }); + + it("should return 404 when JSON blob is not found", async () => { + // Arrange + const mockArtefact = { + artefactId: "test-artefact-123", + locationId: "9001", + listTypeId: 999, + contentDate: new Date("2026-01-15"), + displayFrom: new Date("2026-01-15"), + displayTo: new Date("2026-01-15"), + lastReceivedDate: new Date("2026-01-15T12:00:00Z"), + provenance: "MANUAL_UPLOAD" + }; + + req.query = { artefactId: "test-artefact-123" }; + vi.mocked(getArtefactById).mockResolvedValue(mockArtefact as any); + vi.mocked(getPublicationJson).mockResolvedValue(null); + + // Act + await GET(req as Request, res as Response); + + // Assert + expect(res.status).toHaveBeenCalledWith(404); + expect(res.render).toHaveBeenCalledWith( + "errors/common", + expect.objectContaining({ + errorTitle: "Not Found", + errorMessage: "The requested list could not be found" + }) + ); + }); + + it("should return 400 when JSON validation fails", async () => { + // Arrange + const mockArtefact = { + artefactId: "test-artefact-123", + locationId: "9001", + listTypeId: 999, + contentDate: new Date("2026-01-15"), + displayFrom: new Date("2026-01-15"), + displayTo: new Date("2026-01-15"), + lastReceivedDate: new Date("2026-01-15T12:00:00Z"), + provenance: "MANUAL_UPLOAD" + }; + + req.query = { artefactId: "test-artefact-123" }; + vi.mocked(getArtefactById).mockResolvedValue(mockArtefact as any); + vi.mocked(getPublicationJson).mockResolvedValue([{ time: "10:00am" }]); + mockValidate.mockReturnValue({ isValid: false, errors: ["Missing required field: caseReferenceNumber"] }); + + // Act + await GET(req as Request, res as Response); + + // Assert + expect(res.status).toHaveBeenCalledWith(400); + expect(res.render).toHaveBeenCalledWith( + "errors/common", + expect.objectContaining({ + errorTitle: "Invalid Data", + errorMessage: "The list data is invalid" + }) + ); + }); + + it("should return 500 on server error", async () => { + // Arrange + req.query = { artefactId: "test-artefact-123" }; + vi.mocked(getArtefactById).mockRejectedValue(new Error("Database connection failed")); + + // Act + await GET(req as Request, res as Response); + + // Assert + expect(res.status).toHaveBeenCalledWith(500); + expect(res.render).toHaveBeenCalledWith( + "errors/common", + expect.objectContaining({ + errorTitle: "Error", + errorMessage: "An error occurred while displaying the list" + }) + ); + }); + + it("should use Welsh locale when specified", async () => { + // Arrange + const mockArtefact = { + artefactId: "test-artefact-123", + locationId: "9001", + listTypeId: 999, + contentDate: new Date("2026-01-15"), + displayFrom: new Date("2026-01-15"), + displayTo: new Date("2026-01-15"), + lastReceivedDate: new Date("2026-01-15T12:00:00Z"), + provenance: "MANUAL_UPLOAD" + }; + + const mockRenderedData = { + header: { + listTitle: "Welsh title", + hearingDate: "15 January 2026", + lastUpdatedDate: "15 January 2026", + lastUpdatedTime: "12pm" + }, + hearings: [] + }; + + req.query = { artefactId: "test-artefact-123" }; + res.locals = { locale: "cy" }; + vi.mocked(getArtefactById).mockResolvedValue(mockArtefact as any); + vi.mocked(getPublicationJson).mockResolvedValue([]); + mockValidate.mockReturnValue({ isValid: true, errors: [] }); + vi.mocked(renderUtlcDailyHearingListData).mockReturnValue(mockRenderedData); + + // Act + await GET(req as Request, res as Response); + + // Assert + expect(renderUtlcDailyHearingListData).toHaveBeenCalledWith([], expect.objectContaining({ locale: "cy" })); + }); + + it("should use raw provenance when not in PROVENANCE_LABELS", async () => { + // Arrange + const mockArtefact = { + artefactId: "test-artefact-123", + locationId: "9001", + listTypeId: 999, + contentDate: new Date("2026-01-15"), + displayFrom: new Date("2026-01-15"), + displayTo: new Date("2026-01-15"), + lastReceivedDate: new Date("2026-01-15T12:00:00Z"), + provenance: "UNKNOWN_SOURCE" + }; + + const mockRenderedData = { + header: { listTitle: "Title", hearingDate: "15 January 2026", lastUpdatedDate: "15 January 2026", lastUpdatedTime: "12pm" }, + hearings: [] + }; + + req.query = { artefactId: "test-artefact-123" }; + vi.mocked(getArtefactById).mockResolvedValue(mockArtefact as any); + vi.mocked(getPublicationJson).mockResolvedValue([]); + mockValidate.mockReturnValue({ isValid: true, errors: [] }); + vi.mocked(renderUtlcDailyHearingListData).mockReturnValue(mockRenderedData); + + // Act + await GET(req as Request, res as Response); + + // Assert + const renderCall = vi.mocked(res.render).mock.calls[0]; + expect(renderCall[1]).toMatchObject({ dataSource: "UNKNOWN_SOURCE" }); + }); + }); +}); diff --git a/apps/web/src/pages/(list-types)/upper-tribunal-lands-chamber-daily-hearing-list/index.ts b/apps/web/src/pages/(list-types)/upper-tribunal-lands-chamber-daily-hearing-list/index.ts new file mode 100644 index 000000000..6754d75db --- /dev/null +++ b/apps/web/src/pages/(list-types)/upper-tribunal-lands-chamber-daily-hearing-list/index.ts @@ -0,0 +1,40 @@ +import { createJsonValidator } from "@hmcts/list-types-common"; +import { PROVENANCE_LABELS } from "@hmcts/publication"; +import { + upperTribunalLandsChamberDailyHearingListCy as cy, + upperTribunalLandsChamberDailyHearingListEn as en, + renderUtlcDailyHearingListData, + type UtlcHearingList +} from "@hmcts/upper-tribunal-lands-chamber-daily-hearing-list"; +import { schemaPath } from "@hmcts/upper-tribunal-lands-chamber-daily-hearing-list/config"; +import { createSimpleListTypeHandler } from "../list-type-handler.js"; + +const validate = createJsonValidator(schemaPath); + +export const GET = createSimpleListTypeHandler({ + en, + cy, + validate, + logPrefix: "upper-tribunal-lands-chamber-daily-hearing-list", + render: ({ artefact, jsonData, locale, res }) => { + const t = locale === "cy" ? cy : en; + const { header, hearings } = renderUtlcDailyHearingListData(jsonData, { + locale, + courtName: "Upper Tribunal (Lands Chamber)", + contentDate: artefact.contentDate, + lastReceivedDate: artefact.lastReceivedDate.toISOString(), + listTitle: t.pageTitle + }); + const dataSource = PROVENANCE_LABELS[artefact.provenance] || artefact.provenance; + res.render("upper-tribunal-lands-chamber-daily-hearing-list", { + en, + cy, + t, + title: header.listTitle, + header, + hearings, + dataSource, + pdfDownloadUrl: `/api/pdf/${artefact.artefactId}/download` + }); + } +}); diff --git a/apps/web/src/pages/(list-types)/upper-tribunal-lands-chamber-daily-hearing-list/upper-tribunal-lands-chamber-daily-hearing-list.njk b/apps/web/src/pages/(list-types)/upper-tribunal-lands-chamber-daily-hearing-list/upper-tribunal-lands-chamber-daily-hearing-list.njk new file mode 100644 index 000000000..94e075044 --- /dev/null +++ b/apps/web/src/pages/(list-types)/upper-tribunal-lands-chamber-daily-hearing-list/upper-tribunal-lands-chamber-daily-hearing-list.njk @@ -0,0 +1,90 @@ +{% extends "layouts/base-template.njk" %} + +{% block head %} + {{ super() }} + +{% endblock %} + +{% block page_content %} +
+
+ +

{{ header.listTitle }}

+ +

+ {{ t.factLinkText }} {{ t.factAdditionalText }} +

+ +

{{ t.listForDate }} {{ header.hearingDate }}

+

{{ t.lastUpdated }} {{ header.lastUpdatedDate }} {{ t.at }} {{ header.lastUpdatedTime }}

+ +
+ + + {{ t.openingStatementTitle }} + + +
+

{{ t.openingStatement.contactText }}

+

+ + {{ t.openingStatement.observeLinkText }} + +

+
+
+ +
+

{{ t.searchCasesTitle }}

+ + +
+ +
+ + + + + + + + + + + + + + + + {% for hearing in hearings %} + + + + + + + + + + + + {% endfor %} + +
{{ t.tableHeaders.time }}{{ t.tableHeaders.caseReferenceNumber }}{{ t.tableHeaders.caseName }}{{ t.tableHeaders.judges }}{{ t.tableHeaders.members }}{{ t.tableHeaders.hearingType }}{{ t.tableHeaders.venue }}{{ t.tableHeaders.modeOfHearing }}{{ t.tableHeaders.additionalInformation }}
{{ hearing.time }}{{ hearing.caseReferenceNumber }}{{ hearing.caseName }}{{ hearing.judges }}{{ hearing.members }}{{ hearing.hearingType }}{{ hearing.venue }}{{ hearing.modeOfHearing }}{{ hearing.additionalInformation }}
+
+ +

{{ t.dataSource }}: {{ dataSource }}

+ + + +
+
+{% endblock %} diff --git a/apps/web/src/pages/(list-types)/upper-tribunal-tax-and-chancery-chamber-daily-hearing-list/index.test.ts b/apps/web/src/pages/(list-types)/upper-tribunal-tax-and-chancery-chamber-daily-hearing-list/index.test.ts new file mode 100644 index 000000000..f55c349e4 --- /dev/null +++ b/apps/web/src/pages/(list-types)/upper-tribunal-tax-and-chancery-chamber-daily-hearing-list/index.test.ts @@ -0,0 +1,311 @@ +import type { Request, Response } from "express"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockValidate = vi.hoisted(() => vi.fn()); + +vi.mock("@hmcts/list-types-common", () => ({ + createJsonValidator: () => mockValidate +})); + +vi.mock("@hmcts/publication", () => ({ + getArtefactById: vi.fn(), + getPublicationJson: vi.fn(), + PROVENANCE_LABELS: { + MANUAL_UPLOAD: "Manual Upload", + SNL: "ListAssist" + } +})); + +vi.mock("@hmcts/upper-tribunal-tax-and-chancery-chamber-daily-hearing-list", () => ({ + upperTribunalTaxAndChanceryChamberDailyHearingListEn: { + pageTitle: "Upper Tribunal Tax and Chancery Chamber Daily Hearing list", + provenanceLabels: { MANUAL_UPLOAD: "Manual Upload" } + }, + upperTribunalTaxAndChanceryChamberDailyHearingListCy: { + pageTitle: "Upper Tribunal Tax and Chancery Chamber Daily Hearing list", + provenanceLabels: { MANUAL_UPLOAD: "Manual Upload" } + }, + renderUtccDailyHearingListData: vi.fn() +})); + +vi.mock("@hmcts/upper-tribunal-tax-and-chancery-chamber-daily-hearing-list/config", () => ({ + moduleRoot: "/mock/utcc", + schemaPath: "/mock/utcc/schema.json" +})); + +import { getArtefactById, getPublicationJson } from "@hmcts/publication"; +import { renderUtccDailyHearingListData } from "@hmcts/upper-tribunal-tax-and-chancery-chamber-daily-hearing-list"; +import { GET } from "./index.js"; + +describe("Upper Tribunal Tax and Chancery Chamber page controller", () => { + let req: Partial; + let res: Partial; + + beforeEach(() => { + vi.clearAllMocks(); + + req = { + query: {} + }; + + res = { + status: vi.fn().mockReturnThis(), + render: vi.fn(), + locals: { locale: "en" } + }; + }); + + describe("GET handler", () => { + it("should render the list successfully with valid data", async () => { + // Arrange + const mockArtefact = { + artefactId: "test-artefact-123", + locationId: "9001", + listTypeId: 999, + contentDate: new Date("2026-01-15"), + displayFrom: new Date("2026-01-15"), + displayTo: new Date("2026-01-15"), + lastReceivedDate: new Date("2026-01-15T12:00:00Z"), + provenance: "MANUAL_UPLOAD" + }; + + const mockJsonData = [ + { + time: "10:00am", + caseReferenceNumber: "UTTC/2026/0001", + caseName: "Smith v HMRC", + judges: "Judge Smith", + members: "Member Jones", + hearingType: "Substantive hearing", + venue: "Field House", + additionalInformation: "" + } + ]; + + const mockRenderedData = { + header: { + listTitle: "Upper Tribunal Tax and Chancery Chamber Daily Hearing list", + hearingDate: "15 January 2026", + lastUpdatedDate: "15 January 2026", + lastUpdatedTime: "12pm" + }, + hearings: mockJsonData + }; + + req.query = { artefactId: "test-artefact-123" }; + vi.mocked(getArtefactById).mockResolvedValue(mockArtefact as any); + vi.mocked(getPublicationJson).mockResolvedValue(mockJsonData); + mockValidate.mockReturnValue({ isValid: true, errors: [] }); + vi.mocked(renderUtccDailyHearingListData).mockReturnValue(mockRenderedData); + + // Act + await GET(req as Request, res as Response); + + // Assert + expect(getArtefactById).toHaveBeenCalledWith("test-artefact-123"); + expect(getPublicationJson).toHaveBeenCalledWith("test-artefact-123"); + expect(renderUtccDailyHearingListData).toHaveBeenCalledWith(mockJsonData, { + locale: "en", + courtName: "Upper Tribunal Tax and Chancery Chamber", + contentDate: mockArtefact.contentDate, + lastReceivedDate: mockArtefact.lastReceivedDate.toISOString(), + listTitle: "Upper Tribunal Tax and Chancery Chamber Daily Hearing list" + }); + const renderCall = vi.mocked(res.render).mock.calls[0]; + expect(renderCall[0]).toBe("upper-tribunal-tax-and-chancery-chamber-daily-hearing-list"); + expect(renderCall[1]).toMatchObject({ + header: mockRenderedData.header, + hearings: mockRenderedData.hearings, + dataSource: "Manual Upload" + }); + }); + + it("should return 400 when artefactId is missing", async () => { + // Arrange + req.query = {}; + + // Act + await GET(req as Request, res as Response); + + // Assert + expect(res.status).toHaveBeenCalledWith(400); + expect(res.render).toHaveBeenCalledWith( + "errors/common", + expect.objectContaining({ + errorTitle: "Bad Request", + errorMessage: "Missing artefactId parameter" + }) + ); + }); + + it("should return 404 when artefact is not found", async () => { + // Arrange + req.query = { artefactId: "non-existent-artefact" }; + vi.mocked(getArtefactById).mockResolvedValue(null); + + // Act + await GET(req as Request, res as Response); + + // Assert + expect(res.status).toHaveBeenCalledWith(404); + expect(res.render).toHaveBeenCalledWith( + "errors/common", + expect.objectContaining({ + errorTitle: "Not Found", + errorMessage: "The requested list could not be found" + }) + ); + }); + + it("should return 404 when JSON blob is not found", async () => { + // Arrange + const mockArtefact = { + artefactId: "test-artefact-123", + locationId: "9001", + listTypeId: 999, + contentDate: new Date("2026-01-15"), + displayFrom: new Date("2026-01-15"), + displayTo: new Date("2026-01-15"), + lastReceivedDate: new Date("2026-01-15T12:00:00Z"), + provenance: "MANUAL_UPLOAD" + }; + + req.query = { artefactId: "test-artefact-123" }; + vi.mocked(getArtefactById).mockResolvedValue(mockArtefact as any); + vi.mocked(getPublicationJson).mockResolvedValue(null); + + // Act + await GET(req as Request, res as Response); + + // Assert + expect(res.status).toHaveBeenCalledWith(404); + expect(res.render).toHaveBeenCalledWith( + "errors/common", + expect.objectContaining({ + errorTitle: "Not Found", + errorMessage: "The requested list could not be found" + }) + ); + }); + + it("should return 400 when JSON validation fails", async () => { + // Arrange + const mockArtefact = { + artefactId: "test-artefact-123", + locationId: "9001", + listTypeId: 999, + contentDate: new Date("2026-01-15"), + displayFrom: new Date("2026-01-15"), + displayTo: new Date("2026-01-15"), + lastReceivedDate: new Date("2026-01-15T12:00:00Z"), + provenance: "MANUAL_UPLOAD" + }; + + req.query = { artefactId: "test-artefact-123" }; + vi.mocked(getArtefactById).mockResolvedValue(mockArtefact as any); + vi.mocked(getPublicationJson).mockResolvedValue([{ time: "10:00am" }]); + mockValidate.mockReturnValue({ isValid: false, errors: ["Missing required field: caseReferenceNumber"] }); + + // Act + await GET(req as Request, res as Response); + + // Assert + expect(res.status).toHaveBeenCalledWith(400); + expect(res.render).toHaveBeenCalledWith( + "errors/common", + expect.objectContaining({ + errorTitle: "Invalid Data", + errorMessage: "The list data is invalid" + }) + ); + }); + + it("should return 500 on server error", async () => { + // Arrange + req.query = { artefactId: "test-artefact-123" }; + vi.mocked(getArtefactById).mockRejectedValue(new Error("Database connection failed")); + + // Act + await GET(req as Request, res as Response); + + // Assert + expect(res.status).toHaveBeenCalledWith(500); + expect(res.render).toHaveBeenCalledWith( + "errors/common", + expect.objectContaining({ + errorTitle: "Error", + errorMessage: "An error occurred while displaying the list" + }) + ); + }); + + it("should use Welsh locale when specified", async () => { + // Arrange + const mockArtefact = { + artefactId: "test-artefact-123", + locationId: "9001", + listTypeId: 999, + contentDate: new Date("2026-01-15"), + displayFrom: new Date("2026-01-15"), + displayTo: new Date("2026-01-15"), + lastReceivedDate: new Date("2026-01-15T12:00:00Z"), + provenance: "MANUAL_UPLOAD" + }; + + const mockRenderedData = { + header: { + listTitle: "Welsh title", + hearingDate: "15 January 2026", + lastUpdatedDate: "15 January 2026", + lastUpdatedTime: "12pm" + }, + hearings: [] + }; + + req.query = { artefactId: "test-artefact-123" }; + res.locals = { locale: "cy" }; + vi.mocked(getArtefactById).mockResolvedValue(mockArtefact as any); + vi.mocked(getPublicationJson).mockResolvedValue([]); + mockValidate.mockReturnValue({ isValid: true, errors: [] }); + vi.mocked(renderUtccDailyHearingListData).mockReturnValue(mockRenderedData); + + // Act + await GET(req as Request, res as Response); + + // Assert + expect(renderUtccDailyHearingListData).toHaveBeenCalledWith([], expect.objectContaining({ locale: "cy" })); + }); + + it("should use raw provenance when not in PROVENANCE_LABELS", async () => { + // Arrange + const mockArtefact = { + artefactId: "test-artefact-123", + locationId: "9001", + listTypeId: 999, + contentDate: new Date("2026-01-15"), + displayFrom: new Date("2026-01-15"), + displayTo: new Date("2026-01-15"), + lastReceivedDate: new Date("2026-01-15T12:00:00Z"), + provenance: "UNKNOWN_SOURCE" + }; + + const mockRenderedData = { + header: { listTitle: "Title", hearingDate: "15 January 2026", lastUpdatedDate: "15 January 2026", lastUpdatedTime: "12pm" }, + hearings: [] + }; + + req.query = { artefactId: "test-artefact-123" }; + vi.mocked(getArtefactById).mockResolvedValue(mockArtefact as any); + vi.mocked(getPublicationJson).mockResolvedValue([]); + mockValidate.mockReturnValue({ isValid: true, errors: [] }); + vi.mocked(renderUtccDailyHearingListData).mockReturnValue(mockRenderedData); + + // Act + await GET(req as Request, res as Response); + + // Assert + const renderCall = vi.mocked(res.render).mock.calls[0]; + expect(renderCall[1]).toMatchObject({ dataSource: "UNKNOWN_SOURCE" }); + }); + }); +}); diff --git a/apps/web/src/pages/(list-types)/upper-tribunal-tax-and-chancery-chamber-daily-hearing-list/index.ts b/apps/web/src/pages/(list-types)/upper-tribunal-tax-and-chancery-chamber-daily-hearing-list/index.ts new file mode 100644 index 000000000..418d14366 --- /dev/null +++ b/apps/web/src/pages/(list-types)/upper-tribunal-tax-and-chancery-chamber-daily-hearing-list/index.ts @@ -0,0 +1,40 @@ +import { createJsonValidator } from "@hmcts/list-types-common"; +import { PROVENANCE_LABELS } from "@hmcts/publication"; +import { + upperTribunalTaxAndChanceryChamberDailyHearingListCy as cy, + upperTribunalTaxAndChanceryChamberDailyHearingListEn as en, + renderUtccDailyHearingListData, + type UtccHearingList +} from "@hmcts/upper-tribunal-tax-and-chancery-chamber-daily-hearing-list"; +import { schemaPath } from "@hmcts/upper-tribunal-tax-and-chancery-chamber-daily-hearing-list/config"; +import { createSimpleListTypeHandler } from "../list-type-handler.js"; + +const validate = createJsonValidator(schemaPath); + +export const GET = createSimpleListTypeHandler({ + en, + cy, + validate, + logPrefix: "upper-tribunal-tax-and-chancery-chamber-daily-hearing-list", + render: ({ artefact, jsonData, locale, res }) => { + const t = locale === "cy" ? cy : en; + const { header, hearings } = renderUtccDailyHearingListData(jsonData, { + locale, + courtName: "Upper Tribunal Tax and Chancery Chamber", + contentDate: artefact.contentDate, + lastReceivedDate: artefact.lastReceivedDate.toISOString(), + listTitle: t.pageTitle + }); + const dataSource = PROVENANCE_LABELS[artefact.provenance] || artefact.provenance; + res.render("upper-tribunal-tax-and-chancery-chamber-daily-hearing-list", { + en, + cy, + t, + title: header.listTitle, + header, + hearings, + dataSource, + pdfDownloadUrl: `/api/pdf/${artefact.artefactId}/download` + }); + } +}); diff --git a/apps/web/src/pages/(list-types)/upper-tribunal-tax-and-chancery-chamber-daily-hearing-list/upper-tribunal-tax-and-chancery-chamber-daily-hearing-list.njk b/apps/web/src/pages/(list-types)/upper-tribunal-tax-and-chancery-chamber-daily-hearing-list/upper-tribunal-tax-and-chancery-chamber-daily-hearing-list.njk new file mode 100644 index 000000000..18d4e9899 --- /dev/null +++ b/apps/web/src/pages/(list-types)/upper-tribunal-tax-and-chancery-chamber-daily-hearing-list/upper-tribunal-tax-and-chancery-chamber-daily-hearing-list.njk @@ -0,0 +1,88 @@ +{% extends "layouts/base-template.njk" %} + +{% block head %} + {{ super() }} + +{% endblock %} + +{% block page_content %} +
+
+ +

{{ header.listTitle }}

+ +

+ {{ t.factLinkText }} {{ t.factAdditionalText }} +

+ +

{{ t.listForDate }} {{ header.hearingDate }}

+

{{ t.lastUpdated }} {{ header.lastUpdatedDate }} {{ t.at }} {{ header.lastUpdatedTime }}

+ +
+ + + {{ t.openingStatementTitle }} + + +
+

{{ t.openingStatement.contactText }}

+

+ + {{ t.openingStatement.observeLinkText }} + +

+
+
+ +
+

{{ t.searchCasesTitle }}

+ + +
+ +
+ + + + + + + + + + + + + + + {% for hearing in hearings %} + + + + + + + + + + + {% endfor %} + +
{{ t.tableHeaders.time }}{{ t.tableHeaders.caseReferenceNumber }}{{ t.tableHeaders.caseName }}{{ t.tableHeaders.judges }}{{ t.tableHeaders.members }}{{ t.tableHeaders.hearingType }}{{ t.tableHeaders.venue }}{{ t.tableHeaders.additionalInformation }}
{{ hearing.time }}{{ hearing.caseReferenceNumber }}{{ hearing.caseName }}{{ hearing.judges }}{{ hearing.members }}{{ hearing.hearingType }}{{ hearing.venue }}{{ hearing.additionalInformation }}
+
+ +

{{ t.dataSource }}: {{ dataSource }}

+ + + +
+
+{% endblock %} diff --git a/docs/tickets/425/plan.md b/docs/tickets/425/plan.md new file mode 100644 index 000000000..bd255495e --- /dev/null +++ b/docs/tickets/425/plan.md @@ -0,0 +1,551 @@ +# Technical Plan: Issue #425 - UT Non-Strategic Publishing (UTCC, UTLC, UTAAC) + +## Technical Approach + +Three new non-strategic list type modules are required, each following the same pattern as `libs/list-types/care-standards-tribunal-weekly-hearing-list`. Each module is self-contained with its own Excel converter config, JSON schema, TypeScript types, renderer, PDF generator, email summary extractor, page controller, and Nunjucks templates. Once all three modules are created, a set of shared integration points are updated once each. + +The three list types and their IDs (next free IDs in `list-type-data.ts` are 28, 29, 30): + +| ID | Name (converter/DB key) | Module dir | Region | +|----|-------------------------|------------|--------| +| 28 | `UT_TAX_AND_CHANCERY_CHAMBER_DAILY_HEARING_LIST` | `upper-tribunal-tax-and-chancery-chamber-daily-hearing-list` | National | +| 29 | `UT_LANDS_CHAMBER_DAILY_HEARING_LIST` | `upper-tribunal-lands-chamber-daily-hearing-list` | National | +| 30 | `UT_ADMINISTRATIVE_APPEALS_CHAMBER_DAILY_HEARING_LIST` | `upper-tribunal-administrative-appeals-chamber-daily-hearing-list` | London | + +Abbreviations used throughout: UTCC = UT Tax and Chancery Chamber, UTLC = UT Lands Chamber, UTAAC = UT Administrative Appeals Chamber. + +## Implementation Details + +### File Structure + +Each module follows the CST reference pattern exactly: + +``` +libs/list-types/upper-tribunal-tax-and-chancery-chamber-daily-hearing-list/ +├── package.json +├── tsconfig.json +└── src/ + ├── config.ts + ├── index.ts + ├── config.test.ts + ├── conversion/ + │ └── utcc-config.ts + ├── models/ + │ └── types.ts + ├── schemas/ + │ └── upper-tribunal-tax-and-chancery-chamber-daily-hearing-list.json + ├── rendering/ + │ ├── renderer.ts + │ └── renderer.test.ts + ├── pdf/ + │ ├── pdf-generator.ts + │ ├── pdf-generator.test.ts + │ └── pdf-template.njk + ├── email-summary/ + │ ├── summary-builder.ts + │ └── summary-builder.test.ts + └── pages/ + ├── index.ts + ├── index.test.ts + ├── upper-tribunal-tax-and-chancery-chamber-daily-hearing-list.njk + ├── en.ts + └── cy.ts +``` + +The UTLC and UTAAC modules have the same structure with their respective names replacing `utcc` and `upper-tribunal-tax-and-chancery-chamber-daily-hearing-list`. + +### package.json + +Identical to CST, with the module name updated. The `build:nunjucks` script copies both `pages/*.njk` and `pdf/*.njk` to `dist/`. The `build:schemas` script copies JSON schemas to `dist/schemas/`. + +```json +{ + "name": "@hmcts/upper-tribunal-tax-and-chancery-chamber-daily-hearing-list", + "scripts": { + "build": "tsc && yarn build:nunjucks && yarn build:schemas", + "build:nunjucks": "mkdir -p dist/pages && cd src/pages && find . -name '*.njk' -exec sh -c 'mkdir -p ../../dist/pages/$(dirname {}) && cp {} ../../dist/pages/{}' \\; && cd ../.. && mkdir -p dist/pdf && cd src/pdf && find . -name '*.njk' -exec sh -c 'mkdir -p ../../dist/pdf/$(dirname {}) && cp {} ../../dist/pdf/{}' \\;", + "build:schemas": "mkdir -p dist/schemas && cp src/schemas/*.json dist/schemas/" + } +} +``` + +### config.ts + +Each module exports `moduleRoot`, `pageRoutes` (with the URL-path prefix), and `assets`: + +```typescript +// libs/list-types/upper-tribunal-tax-and-chancery-chamber-daily-hearing-list/src/config.ts +export const pageRoutes = { + path: path.join(__dirname, "pages"), + prefix: "/upper-tribunal-tax-and-chancery-chamber-daily-hearing-list" +}; +``` + +URL paths: +- UTCC: `/upper-tribunal-tax-and-chancery-chamber-daily-hearing-list` +- UTLC: `/upper-tribunal-lands-chamber-daily-hearing-list` +- UTAAC: `/upper-tribunal-administrative-appeals-chamber-daily-hearing-list` + +### index.ts + +Side-effect import of the converter config to register the converter on module load, plus re-exports of email summary, types, PDF generator, and renderer: + +```typescript +import "./conversion/utcc-config.js"; +export type { ValidationResult } from "@hmcts/publication"; +export * from "./email-summary/summary-builder.js"; +export * from "./models/types.js"; +export * from "./pdf/pdf-generator.js"; +export * from "./rendering/renderer.js"; +``` + +### TypeScript Types (models/types.ts) + +**UTCC** (8 fields): +```typescript +export interface UtccHearing { + time: string; + caseReference: string; + caseName: string; + judges: string; + members: string; + hearingType: string; + venue: string; + additionalInformation?: string; +} +export type UtccHearingList = UtccHearing[]; +``` + +**UTLC** (9 fields, adds `modeOfHearing`): +```typescript +export interface UtlcHearing { + time: string; + caseReference: string; + caseName: string; + judges: string; + members: string; + hearingType: string; + venue: string; + modeOfHearing: string; + additionalInformation?: string; +} +export type UtlcHearingList = UtlcHearing[]; +``` + +**UTAAC** (9 fields, adds `appellant` and `modeOfHearing`, renames `caseReference` to `caseReferenceNumber`): +```typescript +export interface UtaacHearing { + time: string; + appellant: string; + caseReferenceNumber: string; + caseName: string; + judges: string; + members: string; + modeOfHearing: string; + venue: string; + additionalInformation?: string; +} +export type UtaacHearingList = UtaacHearing[]; +``` + +`additionalInformation` is optional in all three types (and marked not-required in the JSON schema). `members` is required in all three. + +### JSON Schemas (schemas/.json) + +Each schema is a JSON Schema Draft-07 array of objects. Key rules shared by all three: +- `minItems: 1` (at least one row) +- `time`: required, no-HTML string, free text (time format is not currently validated at schema level — see open questions) +- `members`: required, no-HTML string +- `additionalInformation`: optional, no-HTML string (not in `required` array) +- All other listed text fields: required, no-HTML string + +The no-HTML pattern (same as CST): `"^(?!(.|\\r|\\n)*<[^>]+>)(.|\\r|\\n)*$"` + +UTCC required fields: `time`, `caseReference`, `caseName`, `judges`, `members`, `hearingType`, `venue` +UTLC required fields: `time`, `caseReference`, `caseName`, `judges`, `members`, `hearingType`, `venue`, `modeOfHearing` +UTAAC required fields: `time`, `appellant`, `caseReferenceNumber`, `caseName`, `judges`, `members`, `modeOfHearing`, `venue` + +### Converter Config (conversion/-config.ts) + +Follows the CST pattern. Registers by ID and by name. UTCC example: + +```typescript +import { + createConverter, + type ExcelConverterConfig, + registerConverter, + registerConverterByName, + validateNoHtmlTags +} from "@hmcts/list-types-common"; + +export const UTCC_EXCEL_CONFIG: ExcelConverterConfig = { + fields: [ + { header: "Time", fieldName: "time", required: true, + validators: [(v, r) => validateNoHtmlTags(v, "Time", r)] }, + { header: "Case Reference", fieldName: "caseReference", required: true, + validators: [(v, r) => validateNoHtmlTags(v, "Case Reference", r)] }, + { header: "Case Name", fieldName: "caseName", required: true, + validators: [(v, r) => validateNoHtmlTags(v, "Case Name", r)] }, + { header: "Judge(s)", fieldName: "judges", required: true, + validators: [(v, r) => validateNoHtmlTags(v, "Judge(s)", r)] }, + { header: "Member(s)", fieldName: "members", required: true, + validators: [(v, r) => validateNoHtmlTags(v, "Member(s)", r)] }, + { header: "Hearing Type", fieldName: "hearingType", required: true, + validators: [(v, r) => validateNoHtmlTags(v, "Hearing Type", r)] }, + { header: "Venue", fieldName: "venue", required: true, + validators: [(v, r) => validateNoHtmlTags(v, "Venue", r)] }, + { header: "Additional Information", fieldName: "additionalInformation", required: false, + validators: [(v, r) => validateNoHtmlTags(v, "Additional Information", r)] } + ], + minRows: 1 +}; + +const utccConverter = createConverter(UTCC_EXCEL_CONFIG); +registerConverter(28, utccConverter); +registerConverterByName("UT_TAX_AND_CHANCERY_CHAMBER_DAILY_HEARING_LIST", utccConverter); +``` + +UTLC adds `{ header: "Mode of Hearing", fieldName: "modeOfHearing", required: true, ... }`. Registered with ID 29 and name `UT_LANDS_CHAMBER_DAILY_HEARING_LIST`. + +UTAAC has `appellant` and `caseReferenceNumber` (not `caseReference`), plus `modeOfHearing`, and drops `hearingType`. Registered with ID 30 and name `UT_ADMINISTRATIVE_APPEALS_CHAMBER_DAILY_HEARING_LIST`. + +### Renderer (rendering/renderer.ts) + +The renderer mirrors the CST pattern. It formats `time` as-is (pass-through — no date parsing needed, it is a time string). The `contentDate` passed in from the artefact is formatted as the hearing list date in the header. + +```typescript +export interface RenderOptions { + locale: string; + courtName: string; + contentDate: Date; + lastReceivedDate: string; + listTitle: string; +} + +export interface RenderedData { + header: { + listTitle: string; + hearingDate: string; + lastUpdatedDate: string; + lastUpdatedTime: string; + }; + hearings: UtccHearing[]; // (or UtlcHearing[] / UtaacHearing[]) +} +``` + +The `hearingDate` replaces `weekCommencingDate` from CST because these are daily lists. It is formatted from `contentDate` using `formatDisplayDate`. + +### Opening Statements in en.ts + +The opening statement text is placed in `en.ts` as a structured object with links. The "Observe a court or tribunal hearing" link is masked to `https://www.gov.uk/guidance/observe-a-court-or-tribunal-hearing` as required. + +**UTCC opening statement fields in en.ts:** +```typescript +openingStatement: { + contactText: "A representative of the media, or any other person, wishing to attend a remote hearing should contact uttc@justice.gov.uk and we will arrange for your attendance.", + observeLinkText: "Observe a court or tribunal hearing as a journalist, researcher or member of the public", + observeLinkUrl: "https://www.gov.uk/guidance/observe-a-court-or-tribunal-hearing" +} +``` + +**UTLC opening statement fields in en.ts:** +```typescript +openingStatement: { + contactText: "If a representative of the media or a member of the public wishes to attend a Cloud Video Platform (CVP) hearing they should contact the Lands Chamber listing section Lands@justice.gov.uk who will provide further information.", + observeLinkText: "Observe a court or tribunal hearing as a journalist, researcher or member of the public", + observeLinkUrl: "https://www.gov.uk/guidance/observe-a-court-or-tribunal-hearing" +} +``` + +**UTAAC opening statement** is longer and contains multiple paragraphs for England/Wales and Scotland, with contact emails `adminappeals@justice.gov.uk` and `UTAACMailbox@justice.gov.uk`. The "Observe" link text/URL is confirmed to appear in UTAAC (the ticket requires the link wherever displayed — see open questions for clarification). The full text is structured as an array of paragraph strings in `en.ts`. + +Welsh (`cy.ts`) uses `[TRANSLATE: "..."]` markers for all strings pending content team delivery. + +### Email Summary Builder (email-summary/summary-builder.ts) + +All three share the same per-hearing fields: **Date** (from `artefact.contentDate`), **Time**, **Case Reference Number**. The date value is the content date of the artefact (passed in as a formatted string), not a field from the row data. + +```typescript +export function extractCaseSummary(jsonData: UtccHearingList, contentDate: string): CaseSummary[] { + return jsonData.map((hearing) => [ + { label: "Date", value: contentDate }, + { label: "Time", value: hearing.time || "" }, + { label: "Case Reference Number", value: hearing.caseReference || "" } + ]); +} +``` + +Note: UTAAC uses `hearing.caseReferenceNumber` rather than `hearing.caseReference`. The `formatCaseSummaryForEmail` and `SPECIAL_CATEGORY_DATA_WARNING` are re-exported from `@hmcts/list-types-common` as in CST. + +The current CST `extractCaseSummary` signature takes only `jsonData`. The UT modules will need to accept `contentDate` as a second parameter. Check how the email summary is called in `notification-service.ts` — if the `extract` function signature is `(jsonData: unknown) => CaseSummary[]`, contentDate will need to be baked in or the interface extended. The simplest approach is to have the `summary-builder.ts` export a factory function `createExtractCaseSummary(contentDate: string)` that returns a `SummaryExtractor`-compatible function. Alternatively, the summary builder can expose a wrapper that reads the date from the first hearing row if available. This is a design decision to resolve before implementation (see open questions). + +### PDF Generator (pdf/pdf-generator.ts) + +Mirrors the CST pattern. UTAAC has 9 columns which may require a landscape page orientation. Pass `landscape: true` to `generatePdfFromHtml` if that function supports it, or apply a CSS `@page { size: A4 landscape; }` rule in the `pdf-template.njk` for UTAAC only. + +### Nunjucks Templates + +**Style guide page (`pages/.njk`):** Extends `layouts/base-template.njk`, uses `{% block page_content %}`. Renders a GOV.UK table with the appropriate columns per list type. Includes the opening statement in a `govuk-details` component (open by default, as per CST pattern). Contains a client-side search input and a back-to-top link. Table columns match the field sets above. + +**PDF template (`pdf/pdf-template.njk`):** Standalone HTML with embedded CSS from `pdfStyles`. Renders the same table columns. UTAAC pdf-template includes a `@page { size: A4 landscape; }` style override. + +### Integration Points + +#### 1. `libs/location/src/location-data.ts` + +Add a "National" region (regionId 7) to `regions`. Check: regionId 7 is currently `Yorkshire` — there is no "National" region. A new entry is required: + +```typescript +{ + regionId: 7, + name: "National", + welshName: "[TRANSLATE: National]" +} +``` + +Wait — regionId 7 is taken by `Yorkshire` in the existing data. The next free regionId is 7. Check again: existing regions are 1–6. RegionId 7 is free. But Yorkshire uses regionId 6? Let me re-read: `regionId: 6, name: "Yorkshire"`. So regionId 7 is free and can be used for "National". + +Add three new sub-jurisdictions (subJurisdictionId 10, 11, 12) under jurisdiction 4 (Tribunal): + +```typescript +{ + subJurisdictionId: 10, + name: "Upper Tribunal Tax and Chancery Chamber", + welshName: "[TRANSLATE: Upper Tribunal Tax and Chancery Chamber]", + jurisdictionId: 4 +}, +{ + subJurisdictionId: 11, + name: "Upper Tribunal Lands Chamber", + welshName: "[TRANSLATE: Upper Tribunal Lands Chamber]", + jurisdictionId: 4 +}, +{ + subJurisdictionId: 12, + name: "Upper Tribunal Administrative Appeals Chamber", + welshName: "[TRANSLATE: Upper Tribunal Administrative Appeals Chamber]", + jurisdictionId: 4 +} +``` + +Add three new locations (or reuse a virtual location like the SJP "Single Justice Procedure" entry). Because UT (T&CC) and UT (LC) are "National" and UT (AAC) is "London", virtual locations are needed that cover those regions: + +```typescript +{ + locationId: 13, + name: "Upper Tribunal Tax and Chancery Chamber", + welshName: "[TRANSLATE: Upper Tribunal Tax and Chancery Chamber]", + regions: [7], // National + subJurisdictions: [10] +}, +{ + locationId: 14, + name: "Upper Tribunal Lands Chamber", + welshName: "[TRANSLATE: Upper Tribunal Lands Chamber]", + regions: [7], // National + subJurisdictions: [11] +}, +{ + locationId: 15, + name: "Upper Tribunal Administrative Appeals Chamber", + welshName: "[TRANSLATE: Upper Tribunal Administrative Appeals Chamber]", + regions: [1], // London (regionId 1) + subJurisdictions: [12] +} +``` + +#### 2. `libs/location/src/list-type-data.ts` + +Append three entries at the end of `listTypeData`. The `shortenedFriendlyName` field carries the upload-form label from the ticket: + +```typescript +{ + id: 28, + name: "UT_TAX_AND_CHANCERY_CHAMBER_DAILY_HEARING_LIST", + englishFriendlyName: "Upper Tribunal Tax and Chancery Chamber Daily Hearing list", + welshFriendlyName: "[TRANSLATE: Upper Tribunal Tax and Chancery Chamber Daily Hearing list]", + provenance: "MANUAL_UPLOAD", + urlPath: "upper-tribunal-tax-and-chancery-chamber-daily-hearing-list", + isNonStrategic: true, + defaultSensitivity: "Public", + shortenedFriendlyName: "UT (T and CC) Daily Hearing List", + subJurisdictionIds: [10] +}, +{ + id: 29, + name: "UT_LANDS_CHAMBER_DAILY_HEARING_LIST", + englishFriendlyName: "Upper Tribunal (Lands Chamber) Daily Hearing list", + welshFriendlyName: "[TRANSLATE: Upper Tribunal (Lands Chamber) Daily Hearing list]", + provenance: "MANUAL_UPLOAD", + urlPath: "upper-tribunal-lands-chamber-daily-hearing-list", + isNonStrategic: true, + defaultSensitivity: "Public", + shortenedFriendlyName: "UT (LC) Daily Hearing List", + subJurisdictionIds: [11] +}, +{ + id: 30, + name: "UT_ADMINISTRATIVE_APPEALS_CHAMBER_DAILY_HEARING_LIST", + englishFriendlyName: "Upper Tribunal (Administrative Appeals Chamber) Daily Hearing list", + welshFriendlyName: "[TRANSLATE: Upper Tribunal (Administrative Appeals Chamber) Daily Hearing list]", + provenance: "MANUAL_UPLOAD", + urlPath: "upper-tribunal-administrative-appeals-chamber-daily-hearing-list", + isNonStrategic: true, + defaultSensitivity: "Public", + shortenedFriendlyName: "UT (AAC) Daily Hearing List", + subJurisdictionIds: [12] +} +``` + +Note: IDs 28–30 are the next free IDs as of the current `list-type-data.ts` (last entry is 27). Confirm no parallel branch has added entries before merging. + +#### 3. `libs/publication/src/processing/service.ts` + +Add three entries to `PDF_GENERATOR_REGISTRY`. Import the new generator functions and types: + +```typescript +import { + generateUtccDailyHearingListPdf, + type UtccHearingList +} from "@hmcts/upper-tribunal-tax-and-chancery-chamber-daily-hearing-list"; +import { + generateUtlcDailyHearingListPdf, + type UtlcHearingList +} from "@hmcts/upper-tribunal-lands-chamber-daily-hearing-list"; +import { + generateUtaacDailyHearingListPdf, + type UtaacHearingList +} from "@hmcts/upper-tribunal-administrative-appeals-chamber-daily-hearing-list"; + +// Inside PDF_GENERATOR_REGISTRY: +UT_TAX_AND_CHANCERY_CHAMBER_DAILY_HEARING_LIST: (p) => + generateUtccDailyHearingListPdf({ ...p, jsonData: p.jsonData as UtccHearingList }), +UT_LANDS_CHAMBER_DAILY_HEARING_LIST: (p) => + generateUtlcDailyHearingListPdf({ ...p, jsonData: p.jsonData as UtlcHearingList }), +UT_ADMINISTRATIVE_APPEALS_CHAMBER_DAILY_HEARING_LIST: (p) => + generateUtaacDailyHearingListPdf({ ...p, jsonData: p.jsonData as UtaacHearingList }), +``` + +#### 4. `libs/notifications/src/notification/notification-service.ts` + +Add three entries to `EMAIL_BUILDER_REGISTRY`. Import the extract and format functions: + +```typescript +import { + extractCaseSummary as extractUtccSummary, + formatCaseSummaryForEmail as formatUtccSummaryForEmail +} from "@hmcts/upper-tribunal-tax-and-chancery-chamber-daily-hearing-list"; +// (similar for UTLC and UTAAC) + +// Inside EMAIL_BUILDER_REGISTRY: +UT_TAX_AND_CHANCERY_CHAMBER_DAILY_HEARING_LIST: { + extract: extractUtccSummary as SummaryExtractor, + format: formatUtccSummaryForEmail +}, +``` + +Note: If `extractCaseSummary` in the UT modules has a different signature from `SummaryExtractor` (due to the `contentDate` parameter), the factory/wrapper approach must be resolved first (see open questions). + +#### 5. `apps/web/src/app.ts` + +Add imports and router registration in the same block as `careStandardsTribunalRoutes`: + +```typescript +import { + moduleRoot as utccModuleRoot, + pageRoutes as utccRoutes +} from "@hmcts/upper-tribunal-tax-and-chancery-chamber-daily-hearing-list/config"; +import { + moduleRoot as utlcModuleRoot, + pageRoutes as utlcRoutes +} from "@hmcts/upper-tribunal-lands-chamber-daily-hearing-list/config"; +import { + moduleRoot as utaacModuleRoot, + pageRoutes as utaacRoutes +} from "@hmcts/upper-tribunal-administrative-appeals-chamber-daily-hearing-list/config"; + +// In modulePaths array: +utccModuleRoot, +utlcModuleRoot, +utaacModuleRoot, + +// In router registration block: +app.use(await createSimpleRouter(utccRoutes)); +app.use(await createSimpleRouter(utlcRoutes)); +app.use(await createSimpleRouter(utaacRoutes)); +``` + +#### 6. `libs/list-search-config/src/repository/queries.ts` + +This file exposes the `upsert` function used by the seed/admin code to configure which fields are used for artefact search indexing. The UT list types need rows in `ListSearchConfig` so that case-number and case-name subscriptions work. + +This is typically applied via a migration or seed script rather than by modifying `queries.ts` itself. Confirm with the team whether a seed script (`e2e-tests/utils/seed-list-types.ts` style) or a Prisma migration is the correct mechanism for inserting `ListSearchConfig` rows. The field mapping for each: +- UTCC: `caseNumberFieldName: "caseReference"`, `caseNameFieldName: "caseName"` +- UTLC: `caseNumberFieldName: "caseReference"`, `caseNameFieldName: "caseName"` +- UTAAC: `caseNumberFieldName: "caseReferenceNumber"`, `caseNameFieldName: "caseName"` + +#### 7. Root `tsconfig.json` + +Add three path mappings: + +```json +"@hmcts/upper-tribunal-tax-and-chancery-chamber-daily-hearing-list": [ + "libs/list-types/upper-tribunal-tax-and-chancery-chamber-daily-hearing-list/src" +], +"@hmcts/upper-tribunal-lands-chamber-daily-hearing-list": [ + "libs/list-types/upper-tribunal-lands-chamber-daily-hearing-list/src" +], +"@hmcts/upper-tribunal-administrative-appeals-chamber-daily-hearing-list": [ + "libs/list-types/upper-tribunal-administrative-appeals-chamber-daily-hearing-list/src" +] +``` + +The `/config` sub-path is resolved automatically in the same way as other modules via the `exports` field in each `package.json`. + +## Error Handling and Edge Cases + +- **Missing artefactId**: Return 400, render `errors/common` — same as CST. +- **Artefact not found in DB**: Return 404, render `errors/common`. +- **JSON file missing from storage**: Return 404, render `errors/common`. +- **Schema validation failure**: Return 400, render `errors/common`. Log validation errors server-side (no details to client). +- **PDF generation failure**: Log warning, continue — the publication still succeeds without a PDF (matches existing behaviour in `service.ts`). +- **Notification failure**: Log error, do not rethrow — matches existing notification error handling. +- **`additionalInformation` absent in upload**: The field is optional in the schema and type (`?`). The template must handle absent/undefined gracefully with `{% if hearing.additionalInformation %}` or by defaulting to an empty string in the renderer. +- **UT (AAC) 9-column PDF overflow**: UTAAC has one extra column (Appellant) compared to UTCC. Use landscape orientation in the PDF template as a precaution. +- **Converter registration at module load**: The side-effect import in `index.ts` ensures converters are registered when the module is first imported. Both ID-based and name-based registration handle database ID drift. + +## Acceptance Criteria Mapping + +| Criterion | Implementation | +|-----------|---------------| +| Validation schemas created | JSON schema files in `schemas/` for each module; `createJsonValidator` used in page controller | +| Error handling in validation | `GET` handler returns 400 with `errors/common` on schema validation failure; converter validators reject HTML tags and enforce required fields | +| Valid publications saved via current method | Existing artefact storage unchanged; new list types are processed through the same pipeline in `service.ts` | +| List types classified and user groups decided | `defaultSensitivity: "Public"` set in `list-type-data.ts` entries; `isNonStrategic: true` flags non-strategic route | +| New PDF template for each list | `pdf/pdf-template.njk` created per module with correct columns | +| Unified email summary format | `extractCaseSummary` returns Date, Time, Case Reference Number per hearing; `formatCaseSummaryForEmail` from `@hmcts/list-types-common` used | +| Email summary fields: Date, Time, Case Reference | Implemented in `summary-builder.ts` for all three | +| New style guide for each list | `pages/.njk` with GOV.UK table component, opening statement, and search input | +| List manipulation (search) | Client-side search input in `pages/.njk` as per CST | +| Full names displayed correctly | `englishFriendlyName` values match ticket specification | +| Upload form short names | `shortenedFriendlyName` field added to `list-type-data.ts` entries | +| Region National for UTCC and UTLC | New region `National` (regionId 7) added; virtual locations assigned to it | +| Region London for UTAAC | UTAAC virtual location assigned regionId 1 (London, already exists) | +| Opening statements displayed | Opening statement content in `en.ts`/`cy.ts`; rendered in `govuk-details` component in page template | +| "Observe" link masked to correct URL | `observeLinkUrl: "https://www.gov.uk/guidance/observe-a-court-or-tribunal-hearing"` in all three `en.ts` files | + +## CLARIFICATIONS NEEDED + +1. **Time format**: The ticket does not specify 12-hour vs 24-hour clock for the `time` field. The `@spec` comment noted "12-hour clock assumed — confirm with business." No schema-level time format validation should be added until confirmed. Currently the `time` field is treated as free text. + +2. **contentDate in email summary**: The email summary requires `Date` per hearing row, but the JSON data rows do not contain a date field (unlike CST which has a `date` field per row). The date should be derived from `artefact.contentDate` at the time the email is built. The `SummaryExtractor` type signature in `notification-service.ts` is `(jsonData: unknown) => CaseSummary[]`, which does not pass `contentDate`. A design decision is needed: either (a) change the extractor to a factory `(contentDate: string) => SummaryExtractor`, which requires changes to `notification-service.ts` and the `EmailBuilderConfig` interface, or (b) omit the Date label from the email summary rows and rely on the `publicationDate` already included in the GOV.Notify template parameters. Option (b) is the path of least resistance and avoids changing shared interfaces. Confirm which approach is acceptable before implementing. + +3. **"Observe" link in UTAAC**: The opening statement for UTAAC in the ticket does not explicitly include the "Observe a court or tribunal hearing" hyperlink (unlike UTCC and UTLC which explicitly list it). The ticket acceptance criterion states "The link should be masked wherever displayed." Confirm whether it should appear in the UTAAC opening statement section. + +4. **National region Welsh translation**: Official Welsh for "National" is needed. Use `[TRANSLATE: "National"]` as a placeholder. + +5. **Welsh translations for all new content**: All strings in `cy.ts` files should use `[TRANSLATE: "..."]` markers. Confirm whether this is acceptable for the initial release or if Welsh translations are required before go-live. + +6. **ListSearchConfig insertion mechanism**: Confirm whether the three new `ListSearchConfig` rows should be inserted via a Prisma migration, a seed script, or via the existing admin UI. The `queries.ts` file itself does not need editing; the question is how the rows get into the database. + +7. **ID collision with parallel work**: The `@analyse` comment flagged that issue #428 (SIAC, POAC, PAAC, FFT list types) also allocates new list type IDs from the same sequence. Confirm IDs 28–30 are not already claimed by #428 before merging this branch. diff --git a/docs/tickets/425/tasks.md b/docs/tickets/425/tasks.md new file mode 100644 index 000000000..0744fe5e1 --- /dev/null +++ b/docs/tickets/425/tasks.md @@ -0,0 +1,84 @@ +# Implementation Tasks: Issue #425 + +## Implementation Tasks + +### Module: upper-tribunal-tax-and-chancery-chamber-daily-hearing-list (UTCC, ID 28) + +- [x] Create `libs/list-types/upper-tribunal-tax-and-chancery-chamber-daily-hearing-list/package.json` +- [x] Create `libs/list-types/upper-tribunal-tax-and-chancery-chamber-daily-hearing-list/tsconfig.json` +- [x] Create `libs/list-types/upper-tribunal-tax-and-chancery-chamber-daily-hearing-list/src/config.ts` (exports `moduleRoot`, `pageRoutes` with prefix `/upper-tribunal-tax-and-chancery-chamber-daily-hearing-list`, `assets`) +- [x] Create `libs/list-types/upper-tribunal-tax-and-chancery-chamber-daily-hearing-list/src/config.test.ts` +- [x] Create `libs/list-types/upper-tribunal-tax-and-chancery-chamber-daily-hearing-list/src/models/types.ts` (`UtccHearing`, `UtccHearingList`) +- [x] Create `libs/list-types/upper-tribunal-tax-and-chancery-chamber-daily-hearing-list/src/schemas/upper-tribunal-tax-and-chancery-chamber-daily-hearing-list.json` +- [x] Create `libs/list-types/upper-tribunal-tax-and-chancery-chamber-daily-hearing-list/src/conversion/utcc-config.ts` (registers converter for ID 28 and name `UT_TAX_AND_CHANCERY_CHAMBER_DAILY_HEARING_LIST`) +- [x] Create `libs/list-types/upper-tribunal-tax-and-chancery-chamber-daily-hearing-list/src/rendering/renderer.ts` +- [x] Create `libs/list-types/upper-tribunal-tax-and-chancery-chamber-daily-hearing-list/src/rendering/renderer.test.ts` +- [x] Create `libs/list-types/upper-tribunal-tax-and-chancery-chamber-daily-hearing-list/src/email-summary/summary-builder.ts` (extracts Date, Time, Case Reference Number) +- [x] Create `libs/list-types/upper-tribunal-tax-and-chancery-chamber-daily-hearing-list/src/email-summary/summary-builder.test.ts` +- [x] Create `libs/list-types/upper-tribunal-tax-and-chancery-chamber-daily-hearing-list/src/pdf/pdf-generator.ts` +- [x] Create `libs/list-types/upper-tribunal-tax-and-chancery-chamber-daily-hearing-list/src/pdf/pdf-generator.test.ts` +- [x] Create `libs/list-types/upper-tribunal-tax-and-chancery-chamber-daily-hearing-list/src/pdf/pdf-template.njk` (8 columns: Time, Case Reference, Case Name, Judge(s), Member(s), Hearing Type, Venue, Additional Information) +- [x] Create `libs/list-types/upper-tribunal-tax-and-chancery-chamber-daily-hearing-list/src/pages/en.ts` (includes UTCC opening statement with uttc@justice.gov.uk and "Observe" link) +- [x] Create `libs/list-types/upper-tribunal-tax-and-chancery-chamber-daily-hearing-list/src/pages/cy.ts` (Welsh with `[TRANSLATE: ...]` markers) +- [x] Create `libs/list-types/upper-tribunal-tax-and-chancery-chamber-daily-hearing-list/src/pages/index.ts` (GET controller) +- [x] Create `libs/list-types/upper-tribunal-tax-and-chancery-chamber-daily-hearing-list/src/pages/index.test.ts` +- [x] Create `libs/list-types/upper-tribunal-tax-and-chancery-chamber-daily-hearing-list/src/pages/upper-tribunal-tax-and-chancery-chamber-daily-hearing-list.njk` +- [x] Create `libs/list-types/upper-tribunal-tax-and-chancery-chamber-daily-hearing-list/src/index.ts` + +### Module: upper-tribunal-lands-chamber-daily-hearing-list (UTLC, ID 29) + +- [x] Create `libs/list-types/upper-tribunal-lands-chamber-daily-hearing-list/package.json` +- [x] Create `libs/list-types/upper-tribunal-lands-chamber-daily-hearing-list/tsconfig.json` +- [x] Create `libs/list-types/upper-tribunal-lands-chamber-daily-hearing-list/src/config.ts` (exports `moduleRoot`, `pageRoutes` with prefix `/upper-tribunal-lands-chamber-daily-hearing-list`, `assets`) +- [x] Create `libs/list-types/upper-tribunal-lands-chamber-daily-hearing-list/src/config.test.ts` +- [x] Create `libs/list-types/upper-tribunal-lands-chamber-daily-hearing-list/src/models/types.ts` (`UtlcHearing`, `UtlcHearingList` — includes `modeOfHearing`) +- [x] Create `libs/list-types/upper-tribunal-lands-chamber-daily-hearing-list/src/schemas/upper-tribunal-lands-chamber-daily-hearing-list.json` +- [x] Create `libs/list-types/upper-tribunal-lands-chamber-daily-hearing-list/src/conversion/utlc-config.ts` (registers converter for ID 29 and name `UT_LANDS_CHAMBER_DAILY_HEARING_LIST`) +- [x] Create `libs/list-types/upper-tribunal-lands-chamber-daily-hearing-list/src/rendering/renderer.ts` +- [x] Create `libs/list-types/upper-tribunal-lands-chamber-daily-hearing-list/src/rendering/renderer.test.ts` +- [x] Create `libs/list-types/upper-tribunal-lands-chamber-daily-hearing-list/src/email-summary/summary-builder.ts` (extracts Date, Time, Case Reference Number) +- [x] Create `libs/list-types/upper-tribunal-lands-chamber-daily-hearing-list/src/email-summary/summary-builder.test.ts` +- [x] Create `libs/list-types/upper-tribunal-lands-chamber-daily-hearing-list/src/pdf/pdf-generator.ts` +- [x] Create `libs/list-types/upper-tribunal-lands-chamber-daily-hearing-list/src/pdf/pdf-generator.test.ts` +- [x] Create `libs/list-types/upper-tribunal-lands-chamber-daily-hearing-list/src/pdf/pdf-template.njk` (9 columns: Time, Case Reference, Case Name, Judge(s), Member(s), Hearing Type, Venue, Mode of Hearing, Additional Information) +- [x] Create `libs/list-types/upper-tribunal-lands-chamber-daily-hearing-list/src/pages/en.ts` (includes UTLC opening statement with Lands@justice.gov.uk and "Observe" link) +- [x] Create `libs/list-types/upper-tribunal-lands-chamber-daily-hearing-list/src/pages/cy.ts` (Welsh with `[TRANSLATE: ...]` markers) +- [x] Create `libs/list-types/upper-tribunal-lands-chamber-daily-hearing-list/src/pages/index.ts` (GET controller) +- [x] Create `libs/list-types/upper-tribunal-lands-chamber-daily-hearing-list/src/pages/index.test.ts` +- [x] Create `libs/list-types/upper-tribunal-lands-chamber-daily-hearing-list/src/pages/upper-tribunal-lands-chamber-daily-hearing-list.njk` +- [x] Create `libs/list-types/upper-tribunal-lands-chamber-daily-hearing-list/src/index.ts` + +### Module: upper-tribunal-administrative-appeals-chamber-daily-hearing-list (UTAAC, ID 30) + +- [x] Create `libs/list-types/upper-tribunal-administrative-appeals-chamber-daily-hearing-list/package.json` +- [x] Create `libs/list-types/upper-tribunal-administrative-appeals-chamber-daily-hearing-list/tsconfig.json` +- [x] Create `libs/list-types/upper-tribunal-administrative-appeals-chamber-daily-hearing-list/src/config.ts` (exports `moduleRoot`, `pageRoutes` with prefix `/upper-tribunal-administrative-appeals-chamber-daily-hearing-list`, `assets`) +- [x] Create `libs/list-types/upper-tribunal-administrative-appeals-chamber-daily-hearing-list/src/config.test.ts` +- [x] Create `libs/list-types/upper-tribunal-administrative-appeals-chamber-daily-hearing-list/src/models/types.ts` (`UtaacHearing`, `UtaacHearingList` — includes `appellant`, `caseReferenceNumber`, `modeOfHearing`) +- [x] Create `libs/list-types/upper-tribunal-administrative-appeals-chamber-daily-hearing-list/src/schemas/upper-tribunal-administrative-appeals-chamber-daily-hearing-list.json` +- [x] Create `libs/list-types/upper-tribunal-administrative-appeals-chamber-daily-hearing-list/src/conversion/utaac-config.ts` (registers converter for ID 30 and name `UT_ADMINISTRATIVE_APPEALS_CHAMBER_DAILY_HEARING_LIST`) +- [x] Create `libs/list-types/upper-tribunal-administrative-appeals-chamber-daily-hearing-list/src/rendering/renderer.ts` +- [x] Create `libs/list-types/upper-tribunal-administrative-appeals-chamber-daily-hearing-list/src/rendering/renderer.test.ts` +- [x] Create `libs/list-types/upper-tribunal-administrative-appeals-chamber-daily-hearing-list/src/email-summary/summary-builder.ts` (extracts Date, Time, Case Reference Number using `caseReferenceNumber` field) +- [x] Create `libs/list-types/upper-tribunal-administrative-appeals-chamber-daily-hearing-list/src/email-summary/summary-builder.test.ts` +- [x] Create `libs/list-types/upper-tribunal-administrative-appeals-chamber-daily-hearing-list/src/pdf/pdf-generator.ts` +- [x] Create `libs/list-types/upper-tribunal-administrative-appeals-chamber-daily-hearing-list/src/pdf/pdf-generator.test.ts` +- [x] Create `libs/list-types/upper-tribunal-administrative-appeals-chamber-daily-hearing-list/src/pdf/pdf-template.njk` (9 columns: Time, Appellant, Case Reference Number, Case Name, Judge(s), Member(s), Mode of Hearing, Venue, Additional Information; landscape orientation) +- [x] Create `libs/list-types/upper-tribunal-administrative-appeals-chamber-daily-hearing-list/src/pages/en.ts` (includes UTAAC multi-paragraph opening statement with England/Wales and Scotland sections, adminappeals@justice.gov.uk, UTAACMailbox@justice.gov.uk) +- [x] Create `libs/list-types/upper-tribunal-administrative-appeals-chamber-daily-hearing-list/src/pages/cy.ts` (Welsh with `[TRANSLATE: ...]` markers) +- [x] Create `libs/list-types/upper-tribunal-administrative-appeals-chamber-daily-hearing-list/src/pages/index.ts` (GET controller) +- [x] Create `libs/list-types/upper-tribunal-administrative-appeals-chamber-daily-hearing-list/src/pages/index.test.ts` +- [x] Create `libs/list-types/upper-tribunal-administrative-appeals-chamber-daily-hearing-list/src/pages/upper-tribunal-administrative-appeals-chamber-daily-hearing-list.njk` +- [x] Create `libs/list-types/upper-tribunal-administrative-appeals-chamber-daily-hearing-list/src/index.ts` + +### Shared Integration Points + +- [x] Add `regionId: 7, name: "National"` to `regions` in `libs/location/src/location-data.ts` +- [x] Add sub-jurisdictions 10, 11, 12 (UT Tax and Chancery Chamber, UT Lands Chamber, UT Administrative Appeals Chamber) to `subJurisdictions` in `libs/location/src/location-data.ts` +- [x] Add virtual locations 13, 14, 15 for the three UT chambers to `locations` in `libs/location/src/location-data.ts` +- [x] Append list type entries for IDs 28, 29, 30 to `libs/location/src/list-type-data.ts` (including `shortenedFriendlyName` for upload form labels) +- [x] Add three path mappings to root `tsconfig.json` (`@hmcts/upper-tribunal-tax-and-chancery-chamber-daily-hearing-list`, `@hmcts/upper-tribunal-lands-chamber-daily-hearing-list`, `@hmcts/upper-tribunal-administrative-appeals-chamber-daily-hearing-list`) +- [x] Import and register the three module roots in `modulePaths` and router calls in `apps/web/src/app.ts` +- [x] Add three entries to `PDF_GENERATOR_REGISTRY` in `libs/publication/src/processing/service.ts` +- [x] Add three entries to `EMAIL_BUILDER_REGISTRY` in `libs/notifications/src/notification/notification-service.ts` +- [x] Insert `ListSearchConfig` rows for IDs 28, 29, 30 (via seed script — added to `apps/postgres/prisma/seed.ts`) diff --git a/docs/tickets/425/ticket.md b/docs/tickets/425/ticket.md new file mode 100644 index 000000000..6d8bb5aed --- /dev/null +++ b/docs/tickets/425/ticket.md @@ -0,0 +1,181 @@ +# #425: Style Guide: Tribunal non-strategic publishing - UTCC, UTLC & UTAAC + +**State:** open +**Assignees:** alexbottenberg +**Author:** OgechiOkelu +**Labels:** enhancement, priority:3-medium, type:story +**Created:** 2026-02-27T16:23:17Z +**Updated:** 2026-05-28T14:59:06Z + +## Description + +**PROBLEM STATEMENT** +This ticket is raised for the creation of the validation schema, style guide, PDF and email summary needed for the Upper Tribunal (Tax and Chancery Chamber), Upper Tribunal (Lands Chamber) and the Upper Tribunal (Administrative Appeals Chamber) to publish in CaTH, through the non-strategic publishing route. + +**AS A** Service +**I WANT** to create the validation schema, style guide, PDF & email summary for tribunal hearing lists to be published through the non-strategic publishing route +**SO THAT** the tribunal hearing lists can be published in CaTH + +**ACCEPTANCE CRITERIA** +- Validation schemas are created for each hearing list from the tribunals listed above +- Error handling is put in place for the validation schema +- Valid publications are saved via the current method +- List types are classified and user groups are decided based on authorised access to the list types (Public, Private, etc) +- A new pdf template is created for the downloadable version of each tribunal hearing list +- All the tribunals adopt a unified email summary format similar to the existing email summary +- The fields to be published for the email summary for all the lists are the Date, Time and Case Reference Number +- A new style guide is created for each hearing list of the above-mentioned tribunals +- List manipulation is created for the style guide(s) +- The full list names shall be displayed as follows in the front-end summary of publications: + - Upper Tribunal Tax and Chancery Chamber Daily Hearing list - date + - Upper Tribunal (Lands Chamber) Daily Hearing list – date + - Upper Tribunal (Administrative Appeals Chamber) Daily Hearing list - date + - In the upload form: UT (T and CC) Daily Hearing List, UT (LC) Daily Hearing List, UT (AAC) Daily Hearing List +- The 'Region' for UT (T and CC) Daily Hearing List and UT (LC) Daily Hearing List is 'National' while for UT (AAC) Daily Hearing List is 'London' +- Opening statement for Upper Tribunal (Tax and Chancery Chamber): + > A representative of the media, or any other person, wishing to attend a remote hearing should contact uttc@justice.gov.uk and we will arrange for your attendance. + > Observe a court or tribunal hearing as a journalist, researcher or member of the public +- Fields for UT (T and CC): Time, Case Reference, Case Name, Judge(s), Member(s), Hearing Type, Venue and Additional Information +- Opening statement for Upper Tribunal (Lands Chamber): + > If a representative of the media or a member of the public wishes to attend a Cloud Video Platform (CVP) hearing they should contact the Lands Chamber listing section Lands@justice.gov.uk who will provide further information. + > Observe a court or tribunal hearing as a journalist, researcher or member of the public +- Fields for UT (LC): Time, Case Reference, Case Name, Judge(s), Member(s), Hearing Type, Venue, Mode of Hearing and Additional Information +- Opening statement for Upper Tribunal (Administrative Appeals Chamber): + > Details + > Lists are subject to change until 4:30pm. Any alterations after this time will be telephoned or emailed direct to the parties or their legal representatives. + > + > **England and Wales** + > Remote hearings via CVP and BT Meet Me + > [detailed paragraphs about public attendance via CVP, contact adminappeals@justice.gov.uk] + > + > **Scotland** + > Remote hearings (CVP) + > [detailed paragraph about Scotland CVP hearings, contact UTAACMailbox@justice.gov.uk] +- Fields for UT (AAC): Time, Appellant, Case Reference Number, Case Name, Judge(s), Member(s), Mode of Hearing, Venue and Additional Information +- The link "Observe a court or tribunal hearing as a journalist, researcher or member of the public" should be masked to https://www.gov.uk/guidance/observe-a-court-or-tribunal-hearing wherever displayed + +## Comments + +### Comment by OgechiOkelu on 2026-02-27T16:25:54Z +@SPEC new-feature.md + +### Comment by hmctsclaudecode[bot] on 2026-02-27T16:30:41Z +[Technical Specification generated - see latest @spec comment below for current version] + +### Comment by OgechiOkelu on 2026-02-27T16:36:20Z +@plan + +### Comment by hmctsclaudecode[bot] on 2026-02-27T16:45:14Z +## Technical Plan - Clarifications Needed + +The technical plan has been created on branch `feature/425-ut-non-strategic-publishing` in `docs/tickets/425/`. Before implementation begins, the following questions need answers: + +1. **Welsh translations** - Official Welsh translations are needed from the content team before the service can go live. Are translations available now, or should placeholders be used? +2. **"Observe a court..." link in UTAAC** - Should it appear in the UTAAC opening statement? +3. **`members` field** - required or optional in every row? +4. **`additionalInformation` field** - required or optional? +5. **Region stored on list type or per publication?** +6. **Short name display in upload form** - Should we add a `shortName` field to the interface? + +### Comment by SarahLittlejohn on 2026-03-03T14:33:26Z +@analyse + +### Comment by hmctsclaudecode[bot] on 2026-03-03T14:36:09Z +## Requirements Analysis: Issue #425 + +### Conflicts Detected + +| Conflict Type | This Issue | Conflicts With | Resolution Needed | +|---|---|---|---| +| Parallel shared-file modification | Adds 3 new list type entries to `mock-list-types.ts` and `non-strategic-list-registry.ts` | #428 (SIAC, POAC, PAAC, FFT TC, FFT LRT & FFT RPT) — identical infrastructure touchpoints | Coordinate sequential IDs and merge order | +| Contradictory approach | Extends `mock-list-types.ts` | #342 (Refactor code to use List Information from database table) — requires deleting `mock-list-types.ts` | Determine whether #425 should target the database table directly | + +**Impact Rating:** Medium — touches same shared registration and ID-allocation mechanism as parallel issue #428. Potential conflict with #342 if that advances first. + +### Comment by OgechiOkelu on 2026-05-26T13:48:01Z +@spec + +### Comment by hmctsclaudecode[bot] on 2026-05-26T13:54:21Z +[Full technical spec - see below for summary] + +## Latest Technical Specification (from @spec on 2026-05-26) + +### List Types + +| ID (next free) | DB/converter name | Public-facing summary heading | Upload-form label | Region | +|---|---|---|---|---| +| 28 | `UT_TAX_AND_CHANCERY_CHAMBER_DAILY_HEARING_LIST` | Upper Tribunal Tax and Chancery Chamber Daily Hearing list - {date} | UT (T and CC) Daily Hearing List | National | +| 29 | `UT_LANDS_CHAMBER_DAILY_HEARING_LIST` | Upper Tribunal (Lands Chamber) Daily Hearing list – {date} | UT (LC) Daily Hearing List | National | +| 30 | `UT_ADMINISTRATIVE_APPEALS_CHAMBER_DAILY_HEARING_LIST` | Upper Tribunal (Administrative Appeals Chamber) Daily Hearing list - {date} | UT (AAC) Daily Hearing List | London | + +**Note:** Confirm next free id against latest `list-type-data.ts` before merging. + +### Module Layout (three sibling modules under `libs/list-types/`) + +``` +libs/list-types/upper-tribunal-tax-and-chancery-chamber-daily-hearing-list/ +libs/list-types/upper-tribunal-lands-chamber-daily-hearing-list/ +libs/list-types/upper-tribunal-administrative-appeals-chamber-daily-hearing-list/ +``` + +Each module structure: +``` +src/ +├── config.ts +├── index.ts +├── conversion/-config.ts +├── models/types.ts +├── schemas/.json +├── rendering/renderer.ts +├── pdf/pdf-generator.ts +├── pdf/pdf-template.njk +├── email-summary/summary-builder.ts +├── pages/index.ts +├── pages/.njk +├── pages/en.ts +└── pages/cy.ts +``` + +### Integration Points + +1. `libs/location/src/location-data.ts` – add sub-jurisdiction rows +2. `libs/location/src/list-type-data.ts` – append three entries +3. `libs/publication/src/processing/service.ts` – add to `PDF_GENERATOR_REGISTRY` +4. `libs/notifications/src/notification/notification-service.ts` – add to `EMAIL_BUILDER_REGISTRY` +5. `apps/web/src/app.ts` – import and register pageRoutes and assets +6. `libs/list-search-config/src/repository/queries.ts` – add ListSearchConfig rows + +### URL Paths + +- `/upper-tribunal-tax-and-chancery-chamber-daily-hearing-list` +- `/upper-tribunal-lands-chamber-daily-hearing-list` +- `/upper-tribunal-administrative-appeals-chamber-daily-hearing-list` + +### Table Columns + +- UT (T&CC): Time, Case Reference, Case Name, Judge(s), Member(s), Hearing Type, Venue, Additional Information +- UT (LC): Time, Case Reference, Case Name, Judge(s), Member(s), Hearing Type, Venue, Mode of Hearing, Additional Information +- UT (AAC): Time, Appellant, Case Reference Number, Case Name, Judge(s), Member(s), Mode of Hearing, Venue, Additional Information + +### Validation + +- `additionalInformation` is **optional** in all three schemas +- `members` is **required** in all three schemas +- `modeOfHearing` is **required** for UT (LC) and UT (AAC) +- Time format: 12-hour clock assumed (confirm with business if 24-hour needed) +- All text fields: no HTML tags +- Minimum one row per workbook + +### Email Summary + +All three share the unified non-strategic email template. Per hearing: **Date, Time, Case Reference Number**. + +### Open Questions + +1. **Time format:** 12-hour clock assumed — confirm with business if 24-hour clock. +2. **Region "National":** check if a "National" region row exists in `locationData.regions`; if not, add it. +3. **Sub-jurisdictions:** confirm three separate sub-jurisdictions vs single "Upper Tribunal" sub-jurisdiction. +4. **Date semantics:** email summary `Date` derived from `artefact.contentDate` — confirm acceptable. +5. **Mode of Hearing values:** free text assumed. +6. **PDF page orientation for UT (AAC):** 9 columns — may need landscape. +7. **Welsh translations:** use `[TRANSLATE: "..."]` markers pending content team delivery. diff --git a/e2e-tests/tests/system-admin/third-party-user-management.spec.ts b/e2e-tests/tests/system-admin/third-party-user-management.spec.ts index ef7e1ffef..10ef508f4 100644 --- a/e2e-tests/tests/system-admin/third-party-user-management.spec.ts +++ b/e2e-tests/tests/system-admin/third-party-user-management.spec.ts @@ -3,11 +3,7 @@ import type { Page } from "@playwright/test"; import { expect, test } from "@playwright/test"; import { loginWithSSO } from "../../utils/sso-helpers.js"; import { getTestPrefix } from "../../utils/test-prefix.js"; -import { - createTestThirdPartyUser, - deleteTestThirdPartyUser, - findTestThirdPartyUserByName -} from "../../utils/test-support-api.js"; +import { createTestThirdPartyUser, deleteTestThirdPartyUser, findTestThirdPartyUserByName } from "../../utils/test-support-api.js"; function validateEnvVars() { const missing: string[] = []; @@ -26,195 +22,188 @@ async function authenticateSystemAdmin(page: Page) { } } -test.describe.skip("Third Party User Management", () => { - test.beforeEach(async ({ page }) => { - await authenticateSystemAdmin(page); - }); - - test("user can create a third party user @nightly", async ({ page }) => { - await page.goto("/third-party-subscribers"); - - // Check English heading - await expect(page.getByRole("heading", { level: 1 })).toHaveText("Manage third party users"); - - // Test accessibility - const accessibilityResults = await new AxeBuilder({ page }) - .withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "wcag22aa"]) - .analyze(); - expect(accessibilityResults.violations).toEqual([]); - - // Test Welsh - await page.getByRole("link", { name: "Cymraeg" }).click(); - await expect(page.getByRole("heading", { level: 1 })).toHaveText("Rheoli defnyddwyr trydydd parti"); - await page.getByRole("link", { name: "English" }).click(); - - // Navigate to create (govukButton with href renders with role="button") - await page.getByRole("button", { name: "Create new user" }).click(); - await page.waitForURL("**/third-party-subscribers/create"); - await expect(page.getByRole("heading", { level: 1 })).toHaveText("Create third party user"); - - // Test validation - empty name - await page.getByRole("button", { name: "Continue" }).click(); - await expect(page.locator(".govuk-error-summary")).toBeVisible(); - await expect(page.locator(".govuk-error-message")).toContainText("Enter a name"); - - // Test Welsh on create page - await page.getByRole("link", { name: "Cymraeg" }).click(); - await expect(page.getByRole("heading", { level: 1 })).toHaveText("Creu defnyddiwr trydydd parti"); - await page.getByRole("link", { name: "English" }).click(); - - // Enter valid name with test prefix for cleanup - // Replace underscores with hyphens since validation only allows letters, numbers, spaces, hyphens, apostrophes - const uniqueName = `${getTestPrefix().replace(/_/g, "-")}Test Corp`; - await page.getByLabel("Name").fill(uniqueName); - await page.getByRole("button", { name: "Continue" }).click(); - - // Check summary page - await page.waitForURL("**/third-party-subscribers/create/summary"); - await expect(page.getByRole("heading", { level: 1 })).toHaveText("Create third party user summary"); - await expect(page.locator(".govuk-summary-list")).toContainText(uniqueName); - - // Test accessibility on summary - const summaryAccessibility = await new AxeBuilder({ page }) - .withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "wcag22aa"]) - .analyze(); - expect(summaryAccessibility.violations).toEqual([]); - - // Test Change link - await page.getByRole("link", { name: "Change" }).click(); - await page.waitForURL("**/third-party-subscribers/create"); - await expect(page.getByLabel("Name")).toHaveValue(uniqueName); - - // Go back to summary and confirm - await page.getByRole("button", { name: "Continue" }).click(); - await page.waitForURL("**/third-party-subscribers/create/summary"); - await page.getByRole("button", { name: "Confirm" }).click(); - - // Check confirmation page - await page.waitForURL("**/third-party-subscribers/create/confirmation"); - await expect(page.locator(".govuk-panel--confirmation")).toBeVisible(); - await expect(page.locator(".govuk-panel--confirmation")).toContainText("Third party user created"); - await expect(page.locator(".govuk-panel--confirmation")).toContainText(uniqueName); - - // Navigate back to the list to verify user exists - await page.getByRole("link", { name: "Manage third party users" }).click(); - await page.waitForURL("**/third-party-subscribers"); - await expect(page.locator(".govuk-table")).toContainText(uniqueName); - - // Note: Cleanup handled by global teardown via test prefix - }); +test.describe + .skip("Third Party User Management", () => { + test.beforeEach(async ({ page }) => { + await authenticateSystemAdmin(page); + }); + + test("user can create a third party user @nightly", async ({ page }) => { + await page.goto("/third-party-subscribers"); + + // Check English heading + await expect(page.getByRole("heading", { level: 1 })).toHaveText("Manage third party users"); + + // Test accessibility + const accessibilityResults = await new AxeBuilder({ page }).withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "wcag22aa"]).analyze(); + expect(accessibilityResults.violations).toEqual([]); + + // Test Welsh + await page.getByRole("link", { name: "Cymraeg" }).click(); + await expect(page.getByRole("heading", { level: 1 })).toHaveText("Rheoli defnyddwyr trydydd parti"); + await page.getByRole("link", { name: "English" }).click(); + + // Navigate to create (govukButton with href renders with role="button") + await page.getByRole("button", { name: "Create new user" }).click(); + await page.waitForURL("**/third-party-subscribers/create"); + await expect(page.getByRole("heading", { level: 1 })).toHaveText("Create third party user"); + + // Test validation - empty name + await page.getByRole("button", { name: "Continue" }).click(); + await expect(page.locator(".govuk-error-summary")).toBeVisible(); + await expect(page.locator(".govuk-error-message")).toContainText("Enter a name"); + + // Test Welsh on create page + await page.getByRole("link", { name: "Cymraeg" }).click(); + await expect(page.getByRole("heading", { level: 1 })).toHaveText("Creu defnyddiwr trydydd parti"); + await page.getByRole("link", { name: "English" }).click(); + + // Enter valid name with test prefix for cleanup + // Replace underscores with hyphens since validation only allows letters, numbers, spaces, hyphens, apostrophes + const uniqueName = `${getTestPrefix().replace(/_/g, "-")}Test Corp`; + await page.getByLabel("Name").fill(uniqueName); + await page.getByRole("button", { name: "Continue" }).click(); + + // Check summary page + await page.waitForURL("**/third-party-subscribers/create/summary"); + await expect(page.getByRole("heading", { level: 1 })).toHaveText("Create third party user summary"); + await expect(page.locator(".govuk-summary-list")).toContainText(uniqueName); + + // Test accessibility on summary + const summaryAccessibility = await new AxeBuilder({ page }).withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "wcag22aa"]).analyze(); + expect(summaryAccessibility.violations).toEqual([]); + + // Test Change link + await page.getByRole("link", { name: "Change" }).click(); + await page.waitForURL("**/third-party-subscribers/create"); + await expect(page.getByLabel("Name")).toHaveValue(uniqueName); + + // Go back to summary and confirm + await page.getByRole("button", { name: "Continue" }).click(); + await page.waitForURL("**/third-party-subscribers/create/summary"); + await page.getByRole("button", { name: "Confirm" }).click(); + + // Check confirmation page + await page.waitForURL("**/third-party-subscribers/create/confirmation"); + await expect(page.locator(".govuk-panel--confirmation")).toBeVisible(); + await expect(page.locator(".govuk-panel--confirmation")).toContainText("Third party user created"); + await expect(page.locator(".govuk-panel--confirmation")).toContainText(uniqueName); + + // Navigate back to the list to verify user exists + await page.getByRole("link", { name: "Manage third party users" }).click(); + await page.waitForURL("**/third-party-subscribers"); + await expect(page.locator(".govuk-table")).toContainText(uniqueName); + + // Note: Cleanup handled by global teardown via test prefix + }); + + test("user can manage subscriptions for a third party user @nightly", async ({ page }) => { + // Create a test user via API + const uniqueName = `${getTestPrefix()}Sub Test Corp`; + const testUser = await createTestThirdPartyUser({ name: uniqueName }); + + await page.goto(`/third-party-subscribers/${testUser.id}/manage`); + await expect(page.getByRole("heading", { level: 1 })).toHaveText("Manage user"); + await expect(page.locator(".govuk-summary-list")).toContainText(testUser.name); + + // Test accessibility on manage user page + const accessibilityResults = await new AxeBuilder({ page }).withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "wcag22aa"]).analyze(); + expect(accessibilityResults.violations).toEqual([]); + + // Navigate to subscriptions (govukButton with href renders with role="button") + await page.getByRole("button", { name: "Manage subscriptions" }).click(); + await page.waitForURL(`**/third-party-subscribers/${testUser.id}/subscriptions/manage**`); + await expect(page.getByRole("heading", { level: 1 })).toHaveText("Manage subscriptions"); - test("user can manage subscriptions for a third party user @nightly", async ({ page }) => { - // Create a test user via API - const uniqueName = `${getTestPrefix()}Sub Test Corp`; - const testUser = await createTestThirdPartyUser({ name: uniqueName }); - - await page.goto(`/third-party-subscribers/${testUser.id}/manage`); - await expect(page.getByRole("heading", { level: 1 })).toHaveText("Manage user"); - await expect(page.locator(".govuk-summary-list")).toContainText(testUser.name); - - // Test accessibility on manage user page - const accessibilityResults = await new AxeBuilder({ page }) - .withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "wcag22aa"]) - .analyze(); - expect(accessibilityResults.violations).toEqual([]); - - // Navigate to subscriptions (govukButton with href renders with role="button") - await page.getByRole("button", { name: "Manage subscriptions" }).click(); - await page.waitForURL(`**/third-party-subscribers/${testUser.id}/subscriptions/manage**`); - await expect(page.getByRole("heading", { level: 1 })).toHaveText("Manage subscriptions"); - - // Test Welsh on subscriptions page - await page.getByRole("link", { name: "Cymraeg" }).click(); - await expect(page.getByRole("heading", { level: 1 })).toHaveText("Rheoli tanysgrifiadau"); - await page.getByRole("link", { name: "English" }).click(); - // Wait for page to stabilize after language switch - await expect(page.getByRole("heading", { level: 1 })).toHaveText("Manage subscriptions"); - - // Select a sensitivity for the first list type (radio button variant by default) - const firstPublicRadio = page.locator('input[type="radio"][value="PUBLIC"]').first(); - if (await firstPublicRadio.isVisible()) { - await firstPublicRadio.check(); - } - - // Navigate through pages and save on last page - // Use a max iterations guard to prevent infinite loops - const maxPages = 10; - for (let pageNum = 0; pageNum < maxPages; pageNum++) { - const saveButton = page.getByRole("button", { name: "Save subscriptions" }); - const nextButton = page.getByRole("button", { name: "Next" }); - - // Wait for either button to be visible - await expect(saveButton.or(nextButton)).toBeVisible(); - - if (await saveButton.isVisible()) { - await saveButton.click(); - break; - } - await nextButton.click(); - // Wait for next page to load + // Test Welsh on subscriptions page + await page.getByRole("link", { name: "Cymraeg" }).click(); + await expect(page.getByRole("heading", { level: 1 })).toHaveText("Rheoli tanysgrifiadau"); + await page.getByRole("link", { name: "English" }).click(); + // Wait for page to stabilize after language switch await expect(page.getByRole("heading", { level: 1 })).toHaveText("Manage subscriptions"); - } - // Check confirmation - await page.waitForURL(`**/third-party-subscribers/${testUser.id}/subscriptions/success`); - await expect(page.locator(".govuk-panel--confirmation")).toContainText("Third Party Subscriptions Updated"); - await expect(page.getByRole("link", { name: "Manage third party users" })).toBeVisible(); + // Select a sensitivity for the first list type (radio button variant by default) + const firstPublicRadio = page.locator('input[type="radio"][value="PUBLIC"]').first(); + if (await firstPublicRadio.isVisible()) { + await firstPublicRadio.check(); + } - // Note: Cleanup handled by global teardown via test prefix - }); + // Navigate through pages and save on last page + // Use a max iterations guard to prevent infinite loops + const maxPages = 10; + for (let pageNum = 0; pageNum < maxPages; pageNum++) { + const saveButton = page.getByRole("button", { name: "Save subscriptions" }); + const nextButton = page.getByRole("button", { name: "Next" }); + + // Wait for either button to be visible + await expect(saveButton.or(nextButton)).toBeVisible(); + + if (await saveButton.isVisible()) { + await saveButton.click(); + break; + } + await nextButton.click(); + // Wait for next page to load + await expect(page.getByRole("heading", { level: 1 })).toHaveText("Manage subscriptions"); + } - test("user can delete a third party user @nightly", async ({ page }) => { - // Create a test user via API - const uniqueName = `${getTestPrefix()}Delete Test Corp`; - const testUser = await createTestThirdPartyUser({ name: uniqueName }); - - await page.goto(`/third-party-subscribers/${testUser.id}/manage`); - - // Navigate to delete (govukButton with href renders with role="button") - await page.getByRole("button", { name: "Delete user" }).click(); - await page.waitForURL(`**/third-party-subscribers/${testUser.id}/delete/confirmation`); - await expect(page.getByRole("heading", { level: 1 })).toContainText("Are you sure you want to delete"); - - // Test accessibility - const accessibilityResults = await new AxeBuilder({ page }) - .withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "wcag22aa"]) - .analyze(); - expect(accessibilityResults.violations).toEqual([]); - - // Test Welsh on delete page - await page.getByRole("link", { name: "Cymraeg" }).click(); - await expect(page.getByRole("heading", { level: 1 })).toContainText("Ydych chi'n siŵr"); - await page.getByRole("link", { name: "English" }).click(); - - // Test validation - no radio selected - await page.getByRole("button", { name: "Continue" }).click(); - await expect(page.locator(".govuk-error-summary")).toBeVisible(); - - // Select No - should redirect back to manage user - await page.locator('input[value="no"]').check(); - await page.getByRole("button", { name: "Continue" }).click(); - await page.waitForURL(`**/third-party-subscribers/${testUser.id}/manage`); - await expect(page.getByRole("heading", { level: 1 })).toHaveText("Manage user"); - - // Go back to delete and confirm Yes - await page.getByRole("button", { name: "Delete user" }).click(); - await page.waitForURL(`**/third-party-subscribers/${testUser.id}/delete/confirmation`); - await page.locator('input[value="yes"]').check(); - await page.getByRole("button", { name: "Continue" }).click(); - - // Check deletion success - await page.waitForURL(`**/third-party-subscribers/${testUser.id}/delete/success`); - await expect(page.locator(".govuk-panel--confirmation")).toContainText("Third party user deleted"); - await expect(page.getByRole("heading", { name: "What do you want to do next?" })).toBeVisible(); - await expect(page.getByRole("link", { name: "Manage another third party user" })).toBeVisible(); - await expect(page.getByRole("link", { name: "Home" })).toBeVisible(); - - // Verify user is deleted from list - await page.getByRole("link", { name: "Manage another third party user" }).click(); - await page.waitForURL("**/third-party-subscribers"); - await expect(page.locator("body")).not.toContainText(testUser.name); - - // Note: User already deleted via UI, no cleanup needed + // Check confirmation + await page.waitForURL(`**/third-party-subscribers/${testUser.id}/subscriptions/success`); + await expect(page.locator(".govuk-panel--confirmation")).toContainText("Third Party Subscriptions Updated"); + await expect(page.getByRole("link", { name: "Manage third party users" })).toBeVisible(); + + // Note: Cleanup handled by global teardown via test prefix + }); + + test("user can delete a third party user @nightly", async ({ page }) => { + // Create a test user via API + const uniqueName = `${getTestPrefix()}Delete Test Corp`; + const testUser = await createTestThirdPartyUser({ name: uniqueName }); + + await page.goto(`/third-party-subscribers/${testUser.id}/manage`); + + // Navigate to delete (govukButton with href renders with role="button") + await page.getByRole("button", { name: "Delete user" }).click(); + await page.waitForURL(`**/third-party-subscribers/${testUser.id}/delete/confirmation`); + await expect(page.getByRole("heading", { level: 1 })).toContainText("Are you sure you want to delete"); + + // Test accessibility + const accessibilityResults = await new AxeBuilder({ page }).withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "wcag22aa"]).analyze(); + expect(accessibilityResults.violations).toEqual([]); + + // Test Welsh on delete page + await page.getByRole("link", { name: "Cymraeg" }).click(); + await expect(page.getByRole("heading", { level: 1 })).toContainText("Ydych chi'n siŵr"); + await page.getByRole("link", { name: "English" }).click(); + + // Test validation - no radio selected + await page.getByRole("button", { name: "Continue" }).click(); + await expect(page.locator(".govuk-error-summary")).toBeVisible(); + + // Select No - should redirect back to manage user + await page.locator('input[value="no"]').check(); + await page.getByRole("button", { name: "Continue" }).click(); + await page.waitForURL(`**/third-party-subscribers/${testUser.id}/manage`); + await expect(page.getByRole("heading", { level: 1 })).toHaveText("Manage user"); + + // Go back to delete and confirm Yes + await page.getByRole("button", { name: "Delete user" }).click(); + await page.waitForURL(`**/third-party-subscribers/${testUser.id}/delete/confirmation`); + await page.locator('input[value="yes"]').check(); + await page.getByRole("button", { name: "Continue" }).click(); + + // Check deletion success + await page.waitForURL(`**/third-party-subscribers/${testUser.id}/delete/success`); + await expect(page.locator(".govuk-panel--confirmation")).toContainText("Third party user deleted"); + await expect(page.getByRole("heading", { name: "What do you want to do next?" })).toBeVisible(); + await expect(page.getByRole("link", { name: "Manage another third party user" })).toBeVisible(); + await expect(page.getByRole("link", { name: "Home" })).toBeVisible(); + + // Verify user is deleted from list + await page.getByRole("link", { name: "Manage another third party user" }).click(); + await page.waitForURL("**/third-party-subscribers"); + await expect(page.locator("body")).not.toContainText(testUser.name); + + // Note: User already deleted via UI, no cleanup needed + }); }); -}); diff --git a/libs/list-types/care-standards-tribunal-weekly-hearing-list/src/conversion/cst-config.ts b/libs/list-types/care-standards-tribunal-weekly-hearing-list/src/conversion/cst-config.ts index 47111eecf..df09a025c 100644 --- a/libs/list-types/care-standards-tribunal-weekly-hearing-list/src/conversion/cst-config.ts +++ b/libs/list-types/care-standards-tribunal-weekly-hearing-list/src/conversion/cst-config.ts @@ -7,7 +7,6 @@ import { validateNoHtmlTags } from "@hmcts/list-types-common"; -// Care Standards Tribunal Weekly Hearing List (listTypeId: 9) export const CST_EXCEL_CONFIG: ExcelConverterConfig = { fields: [ { diff --git a/libs/list-types/common/src/conversion/excel-to-json.test.ts b/libs/list-types/common/src/conversion/excel-to-json.test.ts index 50f1d2861..1f5290fa8 100644 --- a/libs/list-types/common/src/conversion/excel-to-json.test.ts +++ b/libs/list-types/common/src/conversion/excel-to-json.test.ts @@ -59,6 +59,63 @@ describe("excel-to-json", () => { expect(result[1]).toEqual({ name: "Jane Smith", email: "" }); }); + it("should treat optional fields as empty string when the column is absent from a row", async () => { + const config: ExcelConverterConfig = { + fields: [ + { header: "Name", fieldName: "name", required: true }, + { header: "Notes", fieldName: "notes", required: false } + ] + }; + + // ExcelJS omits trailing empty cells, so "Notes" won't appear in the row data + const excelData = [ + ["Name", "Notes"], + ["John Doe"] // no Notes cell + ]; + + const buffer = await createExcelBuffer(excelData); + const result = await convertExcelToJson(buffer, config); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ name: "John Doe", notes: "" }); + }); + + it("should throw when a required column is missing from the header row", async () => { + const config: ExcelConverterConfig = { + fields: [ + { header: "Name", fieldName: "name", required: true }, + { header: "Notes", fieldName: "notes", required: false } + ] + }; + + const excelData = [ + ["Notes"], // header row missing "Name" + ["some note"] + ]; + + const buffer = await createExcelBuffer(excelData); + await expect(convertExcelToJson(buffer, config)).rejects.toThrow("Missing: Name"); + }); + + it("should throw missing required field error when a required cell is empty in a row", async () => { + const config: ExcelConverterConfig = { + fields: [ + { header: "Name", fieldName: "name", required: true }, + { header: "Notes", fieldName: "notes", required: false } + ] + }; + + // A row with only Notes filled — Name (required) is absent because ExcelJS omits empty leading/middle cells + // We simulate this by passing undefined for the Name cell position + const excelData = [ + ["Name", "Notes"], + [undefined, "some note"] + ]; + + const buffer = await createExcelBuffer(excelData); + await expect(convertExcelToJson(buffer, config)).rejects.toThrow("Missing required field 'Name' in row 2"); + }); + it("should apply validators to fields", async () => { const config: ExcelConverterConfig = { fields: [ diff --git a/libs/list-types/common/src/conversion/excel-to-json.ts b/libs/list-types/common/src/conversion/excel-to-json.ts index 62cc708fb..01b4d1811 100644 --- a/libs/list-types/common/src/conversion/excel-to-json.ts +++ b/libs/list-types/common/src/conversion/excel-to-json.ts @@ -88,7 +88,7 @@ export async function convertExcelToJson>(buffer: Buf } if (jsonData.length > 0) { - const actualHeaders = Object.keys(jsonData[0] || {}).map((h) => h.toLowerCase().trim()); + const actualHeaders = headers.map((h) => h.toLowerCase().trim()); validateHeaders(actualHeaders, config.fields); } @@ -145,14 +145,13 @@ function getField(row: Record, header: string, rowNumber: numbe const keys = Object.keys(row); const key = keys.find((k) => k.toLowerCase().trim() === header.toLowerCase()); - if (!key) { - throw new Error(`Missing column '${header}'`); - } - - const value = row[key]; + const value = key ? row[key] : undefined; - if (required && (value === null || value === undefined || String(value).trim() === "")) { - throw new Error(`Missing required field '${header}' in row ${rowNumber}`); + if (value === null || value === undefined || String(value).trim() === "") { + if (required) { + throw new Error(`Missing required field '${header}' in row ${rowNumber}`); + } + return ""; } return String(value).trim(); diff --git a/libs/list-types/common/src/index.ts b/libs/list-types/common/src/index.ts index 654d4c727..511d7e84c 100644 --- a/libs/list-types/common/src/index.ts +++ b/libs/list-types/common/src/index.ts @@ -41,6 +41,7 @@ export { type ListPdfOptions, loadTranslations, MAX_PDF_SIZE_BYTES, + type PdfFromHtmlResult, type PdfGenerationResult, type RenderedListData, type RenderedPdfData, diff --git a/libs/list-types/common/src/pdf/pdf-utilities.ts b/libs/list-types/common/src/pdf/pdf-utilities.ts index f098462c3..a610a0ffe 100644 --- a/libs/list-types/common/src/pdf/pdf-utilities.ts +++ b/libs/list-types/common/src/pdf/pdf-utilities.ts @@ -51,6 +51,13 @@ export function createPdfErrorResult(error: unknown): PdfGenerationResult { }; } +export interface PdfFromHtmlResult { + success: boolean; + pdfBuffer?: Buffer; + sizeBytes?: number; + error?: string; +} + export async function loadTranslations( locale: string, importEn: () => Promise<{ en: Record }>, diff --git a/libs/list-types/upper-tribunal-administrative-appeals-chamber-daily-hearing-list/package.json b/libs/list-types/upper-tribunal-administrative-appeals-chamber-daily-hearing-list/package.json new file mode 100644 index 000000000..3928ada30 --- /dev/null +++ b/libs/list-types/upper-tribunal-administrative-appeals-chamber-daily-hearing-list/package.json @@ -0,0 +1,42 @@ +{ + "name": "@hmcts/upper-tribunal-administrative-appeals-chamber-daily-hearing-list", + "version": "1.0.0", + "type": "module", + "exports": { + ".": { + "production": "./dist/index.js", + "default": "./src/index.ts" + }, + "./config": { + "production": "./dist/config.js", + "default": "./src/config.ts" + } + }, + "scripts": { + "build": "tsc && yarn build:nunjucks && yarn build:schemas", + "build:nunjucks": "mkdir -p dist/pdf && cd src/pdf && find . -name '*.njk' -exec sh -c 'mkdir -p ../../dist/pdf/$(dirname {}) && cp {} ../../dist/pdf/{}' \\;", + "build:schemas": "mkdir -p dist/schemas && cp src/schemas/*.json dist/schemas/", + "dev": "tsc --watch", + "test": "vitest run", + "test:watch": "vitest watch", + "format": "biome format --write .", + "lint": "biome check .", + "lint:fix": "biome check --write --unsafe ." + }, + "dependencies": { + "@hmcts/list-types-common": "workspace:*", + "@hmcts/pdf-generation": "workspace:*", + "@hmcts/postgres-prisma": "workspace:*", + "@hmcts/upper-tribunal-common": "workspace:*", + "luxon": "3.7.2" + }, + "devDependencies": { + "@types/luxon": "3.7.1", + "@types/node": "24.10.4", + "typescript": "6.0.3", + "vitest": "4.1.8" + }, + "peerDependencies": { + "express": "^5.1.0" + } +} diff --git a/libs/list-types/upper-tribunal-administrative-appeals-chamber-daily-hearing-list/src/config.test.ts b/libs/list-types/upper-tribunal-administrative-appeals-chamber-daily-hearing-list/src/config.test.ts new file mode 100644 index 000000000..d3a69365b --- /dev/null +++ b/libs/list-types/upper-tribunal-administrative-appeals-chamber-daily-hearing-list/src/config.test.ts @@ -0,0 +1,67 @@ +import { existsSync } from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { assets, moduleRoot, schemaPath } from "./config.js"; + +describe("upper-tribunal-administrative-appeals-chamber-daily-hearing-list config", () => { + describe("moduleRoot", () => { + it("should be defined", () => { + expect(moduleRoot).toBeDefined(); + expect(typeof moduleRoot).toBe("string"); + }); + + it("should point to an existing directory", () => { + expect(existsSync(moduleRoot)).toBe(true); + }); + + it("should be an absolute path", () => { + expect(path.isAbsolute(moduleRoot)).toBe(true); + }); + }); + + describe("schemaPath", () => { + it("should be defined", () => { + expect(schemaPath).toBeDefined(); + expect(typeof schemaPath).toBe("string"); + }); + + it("should point to the schema file", () => { + expect(schemaPath).toContain("upper-tribunal-administrative-appeals-chamber-daily-hearing-list.json"); + }); + + it("should be an absolute path", () => { + expect(path.isAbsolute(schemaPath)).toBe(true); + }); + + it("should point to existing schema file", () => { + expect(existsSync(schemaPath)).toBe(true); + }); + }); + + describe("assets", () => { + it("should be defined", () => { + expect(assets).toBeDefined(); + expect(typeof assets).toBe("string"); + }); + + it("should point to assets directory", () => { + expect(assets).toContain("assets"); + }); + + it("should be an absolute path", () => { + expect(path.isAbsolute(assets)).toBe(true); + }); + + it("should have valid path structure", () => { + expect(assets).toBeTruthy(); + }); + + it("should end with trailing slash", () => { + expect(assets).toMatch(/\/$/); + }); + + it("should be subdirectory of moduleRoot", () => { + expect(assets.startsWith(moduleRoot)).toBe(true); + }); + }); +}); diff --git a/libs/list-types/upper-tribunal-administrative-appeals-chamber-daily-hearing-list/src/config.ts b/libs/list-types/upper-tribunal-administrative-appeals-chamber-daily-hearing-list/src/config.ts new file mode 100644 index 000000000..ee0033149 --- /dev/null +++ b/libs/list-types/upper-tribunal-administrative-appeals-chamber-daily-hearing-list/src/config.ts @@ -0,0 +1,9 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +export const moduleRoot = __dirname; +export const assets = path.join(__dirname, "assets/"); +export const schemaPath = path.join(__dirname, "schemas/upper-tribunal-administrative-appeals-chamber-daily-hearing-list.json"); diff --git a/libs/list-types/upper-tribunal-administrative-appeals-chamber-daily-hearing-list/src/conversion/utaac-config.test.ts b/libs/list-types/upper-tribunal-administrative-appeals-chamber-daily-hearing-list/src/conversion/utaac-config.test.ts new file mode 100644 index 000000000..0692b43ad --- /dev/null +++ b/libs/list-types/upper-tribunal-administrative-appeals-chamber-daily-hearing-list/src/conversion/utaac-config.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; +import { UTAAC_EXCEL_CONFIG } from "./utaac-config.js"; + +describe("UTAAC_EXCEL_CONFIG", () => { + it("should have correct configuration structure", () => { + expect(UTAAC_EXCEL_CONFIG).toBeDefined(); + expect(UTAAC_EXCEL_CONFIG.fields).toHaveLength(8); + expect(UTAAC_EXCEL_CONFIG.minRows).toBe(1); + }); + + it("should have all expected field names", () => { + const fieldNames = UTAAC_EXCEL_CONFIG.fields.map((f) => f.fieldName); + expect(fieldNames).toEqual(["time", "appellant", "caseReferenceNumber", "judges", "members", "modeOfHearing", "venue", "additionalInformation"]); + }); + + it("should have correct required flags", () => { + const requiredFields = UTAAC_EXCEL_CONFIG.fields.filter((f) => f.required).map((f) => f.fieldName); + const optionalFields = UTAAC_EXCEL_CONFIG.fields.filter((f) => !f.required).map((f) => f.fieldName); + + expect(requiredFields).toEqual(["time", "appellant", "caseReferenceNumber", "judges", "members", "modeOfHearing", "venue", "additionalInformation"]); + expect(optionalFields).toEqual([]); + }); + + describe("field validators", () => { + it("should accept values without HTML tags", () => { + for (const field of UTAAC_EXCEL_CONFIG.fields) { + expect(() => field.validators?.[0]("Plain text value", 1)).not.toThrow(); + } + }); + + it("should reject values containing HTML tags", () => { + for (const field of UTAAC_EXCEL_CONFIG.fields) { + expect(() => field.validators?.[0]("bold", 1)).toThrow(); + } + }); + }); +}); diff --git a/libs/list-types/upper-tribunal-administrative-appeals-chamber-daily-hearing-list/src/conversion/utaac-config.ts b/libs/list-types/upper-tribunal-administrative-appeals-chamber-daily-hearing-list/src/conversion/utaac-config.ts new file mode 100644 index 000000000..147a9dd94 --- /dev/null +++ b/libs/list-types/upper-tribunal-administrative-appeals-chamber-daily-hearing-list/src/conversion/utaac-config.ts @@ -0,0 +1,58 @@ +import { createConverter, type ExcelConverterConfig, registerConverterByName, validateNoHtmlTags } from "@hmcts/list-types-common"; + +export const UTAAC_EXCEL_CONFIG: ExcelConverterConfig = { + fields: [ + { + header: "Time", + fieldName: "time", + required: true, + validators: [(value, rowNumber) => validateNoHtmlTags(value, "Time", rowNumber)] + }, + { + header: "Appellant", + fieldName: "appellant", + required: true, + validators: [(value, rowNumber) => validateNoHtmlTags(value, "Appellant", rowNumber)] + }, + { + header: "Case Reference Number", + fieldName: "caseReferenceNumber", + required: true, + validators: [(value, rowNumber) => validateNoHtmlTags(value, "Case Reference Number", rowNumber)] + }, + { + header: "Judge(s)", + fieldName: "judges", + required: true, + validators: [(value, rowNumber) => validateNoHtmlTags(value, "Judge(s)", rowNumber)] + }, + { + header: "Member(s)", + fieldName: "members", + required: true, + validators: [(value, rowNumber) => validateNoHtmlTags(value, "Member(s)", rowNumber)] + }, + { + header: "Mode of Hearing", + fieldName: "modeOfHearing", + required: true, + validators: [(value, rowNumber) => validateNoHtmlTags(value, "Mode of Hearing", rowNumber)] + }, + { + header: "Venue", + fieldName: "venue", + required: true, + validators: [(value, rowNumber) => validateNoHtmlTags(value, "Venue", rowNumber)] + }, + { + header: "Additional Information", + fieldName: "additionalInformation", + required: true, + validators: [(value, rowNumber) => validateNoHtmlTags(value, "Additional Information", rowNumber)] + } + ], + minRows: 1 +}; + +const utaacConverter = createConverter(UTAAC_EXCEL_CONFIG); +registerConverterByName("UT_ADMINISTRATIVE_APPEALS_CHAMBER_DAILY_HEARING_LIST", utaacConverter); diff --git a/libs/list-types/upper-tribunal-administrative-appeals-chamber-daily-hearing-list/src/email-summary/summary-builder.test.ts b/libs/list-types/upper-tribunal-administrative-appeals-chamber-daily-hearing-list/src/email-summary/summary-builder.test.ts new file mode 100644 index 000000000..c78837530 --- /dev/null +++ b/libs/list-types/upper-tribunal-administrative-appeals-chamber-daily-hearing-list/src/email-summary/summary-builder.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from "vitest"; +import type { UtaacHearingList } from "../models/types.js"; +import { extractCaseSummary, formatCaseSummaryForEmail, SPECIAL_CATEGORY_DATA_WARNING } from "./summary-builder.js"; + +describe("SPECIAL_CATEGORY_DATA_WARNING", () => { + it("should contain required warning text", () => { + expect(SPECIAL_CATEGORY_DATA_WARNING).toContain("Special Category Data"); + expect(SPECIAL_CATEGORY_DATA_WARNING).toContain("Data Protection Act 2018"); + }); +}); + +describe("extractCaseSummary", () => { + it("should extract case summaries using caseReferenceNumber field", () => { + const hearingList: UtaacHearingList = [ + { + time: "10:00am", + appellant: "Smith", + caseReferenceNumber: "UTAAC/2025/0001", + judges: "Judge Smith", + members: "Member Jones", + modeOfHearing: "CVP", + venue: "Field House", + additionalInformation: "" + }, + { + time: "2:00pm", + appellant: "Brown", + caseReferenceNumber: "UTAAC/2025/0002", + judges: "Judge Brown", + members: "Member Davis", + modeOfHearing: "In person", + venue: "Field House", + additionalInformation: "" + } + ]; + + const result = extractCaseSummary(hearingList); + + expect(result).toHaveLength(2); + expect(result[0]).toEqual([ + { label: "Time", value: "10:00am" }, + { label: "Case reference number", value: "UTAAC/2025/0001" }, + { label: "Appellant", value: "Smith" } + ]); + expect(result[1]).toEqual([ + { label: "Time", value: "2:00pm" }, + { label: "Case reference number", value: "UTAAC/2025/0002" }, + { label: "Appellant", value: "Brown" } + ]); + }); + + it("should handle empty hearing list", () => { + const hearingList: UtaacHearingList = []; + + const result = extractCaseSummary(hearingList); + + expect(result).toHaveLength(0); + }); + + it("should handle missing values with empty string", () => { + const hearingList: UtaacHearingList = [ + { + time: "", + appellant: "", + caseReferenceNumber: "", + judges: "", + members: "", + modeOfHearing: "", + venue: "", + additionalInformation: "" + } + ]; + + const result = extractCaseSummary(hearingList); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual([ + { label: "Time", value: "" }, + { label: "Case reference number", value: "" }, + { label: "Appellant", value: "" } + ]); + }); +}); + +describe("formatCaseSummaryForEmail", () => { + it("should format single case summary correctly", () => { + const items = [ + [ + { label: "Time", value: "10:00am" }, + { label: "Case reference number", value: "UTAAC/2025/0001" }, + { label: "Appellant", value: "Smith" } + ] + ]; + + const result = formatCaseSummaryForEmail(items); + + expect(result).toContain("---"); + expect(result).toContain("Time - 10:00am"); + expect(result).toContain("Case reference number - UTAAC/2025/0001"); + expect(result).toContain("Appellant - Smith"); + }); + + it("should handle empty case list", () => { + const result = formatCaseSummaryForEmail([]); + + expect(result).toBe("No cases scheduled."); + }); +}); diff --git a/libs/list-types/upper-tribunal-administrative-appeals-chamber-daily-hearing-list/src/email-summary/summary-builder.ts b/libs/list-types/upper-tribunal-administrative-appeals-chamber-daily-hearing-list/src/email-summary/summary-builder.ts new file mode 100644 index 000000000..46a88fae8 --- /dev/null +++ b/libs/list-types/upper-tribunal-administrative-appeals-chamber-daily-hearing-list/src/email-summary/summary-builder.ts @@ -0,0 +1,12 @@ +import { type CaseSummary, formatCaseSummaryForEmail, SPECIAL_CATEGORY_DATA_WARNING } from "@hmcts/list-types-common"; +import type { UtaacHearingList } from "../models/types.js"; + +export { formatCaseSummaryForEmail, SPECIAL_CATEGORY_DATA_WARNING }; + +export function extractCaseSummary(jsonData: UtaacHearingList): CaseSummary[] { + return jsonData.map((hearing) => [ + { label: "Time", value: hearing.time || "" }, + { label: "Case reference number", value: hearing.caseReferenceNumber || "" }, + { label: "Appellant", value: hearing.appellant || "" } + ]); +} diff --git a/libs/list-types/upper-tribunal-administrative-appeals-chamber-daily-hearing-list/src/index.ts b/libs/list-types/upper-tribunal-administrative-appeals-chamber-daily-hearing-list/src/index.ts new file mode 100644 index 000000000..66bef2a08 --- /dev/null +++ b/libs/list-types/upper-tribunal-administrative-appeals-chamber-daily-hearing-list/src/index.ts @@ -0,0 +1,12 @@ +import "./conversion/utaac-config.js"; // Register converter on module load + +// Business logic exports +export type { ValidationResult } from "@hmcts/publication"; +export * from "./email-summary/summary-builder.js"; +export { cy as upperTribunalAdministrativeAppealsChamberDailyHearingListCy } from "./locales/cy.js"; +// Locale exports +export { en as upperTribunalAdministrativeAppealsChamberDailyHearingListEn } from "./locales/en.js"; +export * from "./models/types.js"; +export * from "./pdf/pdf-generator.js"; +export * from "./rendering/renderer.js"; +export { validateUtAdministrativeAppealsChamberDailyHearingList } from "./validation/json-validator.js"; diff --git a/libs/list-types/upper-tribunal-administrative-appeals-chamber-daily-hearing-list/src/locales/cy.ts b/libs/list-types/upper-tribunal-administrative-appeals-chamber-daily-hearing-list/src/locales/cy.ts new file mode 100644 index 000000000..a0edfded5 --- /dev/null +++ b/libs/list-types/upper-tribunal-administrative-appeals-chamber-daily-hearing-list/src/locales/cy.ts @@ -0,0 +1,51 @@ +export const cy = { + pageTitle: "Rhestr Gwrandawiadau Dyddiol Tribiwnlys Uwch (Siambr Apeliadau Gweinyddol)", + downloadPdf: "Lawrlwytho fel PDF", + factLinkText: "Dod o hyd i fanylion cyswllt a gwybodaeth arall am lysoedd a thribiwnlysoedd", + factLinkUrl: "https://www.find-court-tribunal.service.gov.uk/", + factAdditionalText: "yng Nghymru a Lloegr, a rhai tribiwnlysoedd sydd heb eu datganoli yn yr Alban.", + listForDate: "Rhestr ar gyfer", + lastUpdated: "Diweddarwyd ddiwethaf", + at: "am", + openingStatement: { + detailsTitle: "Manylion", + listChangeNotice: + "Mae'r rhestrau yn destun newid hyd at 4:30pm. Bydd unrhyw newidiadau ar ôl yr amser hwn yn cael eu ffonio neu eu hanfon trwy e-bost yn uniongyrchol at y partïon neu eu cynrychiolwyr cyfreithiol.", + englandAndWalesTitle: "Cymru a Lloegr", + englandAndWalesRemoteText: "Gwrandawiadau o bell drwy CVP", + englandAndWalesCvpText: + "Bydd gwrandawiadau ar gael i gynrychiolwyr y cyfryngau neu unrhyw aelod arall o'r cyhoedd, ar eu cais, a fydd felly yn wrandawiad a gynhelir yn gyhoeddus yn unol â Rheol 37 o Reolau Gweithdrefn Tribiwnlysoedd (Tribiwnlys Uwch) 2008.", + englandAndWalesAccessText: + "Bydd angen i unrhyw gynrychiolydd cyfryngau neu unrhyw aelod arall o'r cyhoedd sy'n dymuno tystio i'r gwrandawiad wneud hynny dros y rhyngrwyd a darparu cyfeiriad e-bost i anfon dolen fynediad briodol ato.", + englandAndWalesContactText: "Cysylltwch â adminappeals@justice.gov.uk.", + scotlandTitle: "Yr Alban", + scotlandRemoteText: "Gwrandawiadau o bell", + scotlandCvpText: + "Pan restrir gwrandawiadau ar gyfer yr Alban, bydd y gwrandawiad ar gael i gynrychiolwyr y cyfryngau neu unrhyw aelod arall o'r cyhoedd, ar eu cais, a fydd felly yn wrandawiad a gynhelir yn gyhoeddus yn unol â Rheol 37 o Reolau Gweithdrefn Tribiwnlysoedd (Tribiwnlys Uwch) 2008. Bydd yn cael ei drefnu a'i gynnal gan ddefnyddio Platfform Fideo Cwmwl (CVP). Bydd angen i unrhyw gynrychiolydd cyfryngau neu unrhyw aelod arall o'r cyhoedd sy'n dymuno tystio i'r gwrandawiad wneud hynny dros y rhyngrwyd a darparu cyfeiriad e-bost i anfon dolen fynediad briodol ato. Cysylltwch â UTAACMailbox@justice.gov.uk." + }, + openingStatementTitle: "Gwybodaeth bwysig", + searchCasesTitle: "Chwilio Achosion", + searchCasesLabel: "Chwilio yn ôl cyfeirnod achos, apelydd, enw'r achos, barnwr, lleoliad, neu fanylion eraill", + tableHeaders: { + time: "Amser", + appellant: "Apelydd", + caseReferenceNumber: "Rhif cyfeirnod achos", + judges: "Barnwr/Barnwyr", + members: "Aelod/Aelodau", + modeOfHearing: "Dull gwrandawiad", + venue: "Lleoliad", + additionalInformation: "Gwybodaeth ychwanegol" + }, + dataSource: "Ffynhonnell data", + backToTop: "Yn ôl i frig y dudalen", + cautionNote: + "Noder bod y ddogfen hon yn cynnwys Data Categori Arbennig fel y'i diffinnir yn Neddf Gwarchod Data 2018, a elwid gynt yn Ddata Personol Sensitif, a dylid ei drin yn y ffordd briodol.", + cautionReporting: + "Mae'r ddogfen hon yn cynnwys gwybodaeth a fwriedir i gynorthwyo i roi adroddiad manwl-gywir am achosion llys. Mae'n hanfodol eich bod yn sicrhau eich bod yn gwarchod y Data Categori Arbennig sydd ynddi ac yn cadw at gyfyngiadau adrodd (er enghraifft yn achos dioddefwyr a phlant). Bydd GLlTEF yn rhoi'r gorau i anfon y data os cyfyd pryder ynghylch sut y'i defnyddir.", + provenanceLabels: { + MANUAL_UPLOAD: "Llwytho â Llaw", + XHIBIT: "XHIBIT", + SNL: "SNL", + COMMON_PLATFORM: "Platfform Cyffredin" + } +}; diff --git a/libs/list-types/upper-tribunal-administrative-appeals-chamber-daily-hearing-list/src/locales/en.ts b/libs/list-types/upper-tribunal-administrative-appeals-chamber-daily-hearing-list/src/locales/en.ts new file mode 100644 index 000000000..5fbfad7a6 --- /dev/null +++ b/libs/list-types/upper-tribunal-administrative-appeals-chamber-daily-hearing-list/src/locales/en.ts @@ -0,0 +1,51 @@ +export const en = { + pageTitle: "Upper Tribunal (Administrative Appeals Chamber) Daily Hearing List", + downloadPdf: "Download as PDF", + factLinkText: "Find contact details and other information about courts and tribunals", + factLinkUrl: "https://www.find-court-tribunal.service.gov.uk/", + factAdditionalText: "in England and Wales, and some non-devolved tribunals in Scotland.", + listForDate: "List for", + lastUpdated: "Last updated", + at: "at", + openingStatement: { + detailsTitle: "Details", + listChangeNotice: + "Lists are subject to change until 4:30pm. Any alterations after this time will be telephoned or emailed direct to the parties or their legal representatives.", + englandAndWalesTitle: "England and Wales", + englandAndWalesRemoteText: "Remote hearings via CVP", + englandAndWalesCvpText: + "Hearings will be available to representatives of the media or any other member of the public, on their request, and therefore will be a hearing conducted in public in accordance with Rule 37 of the Tribunal Procedure (Upper Tribunal) Rules 2008.", + englandAndWalesAccessText: + "Any media representative or any other member of the public wishing to witness the hearing will need to do so over the internet and provide an email address at which to be sent an appropriate link for access.", + englandAndWalesContactText: "Please contact adminappeals@justice.gov.uk.", + scotlandTitle: "Scotland", + scotlandRemoteText: "Remote hearings", + scotlandCvpText: + "When hearings are listed for Scotland the hearing will be available to representatives of the media or any other member of the public, on their request, and therefore will be a hearing conducted in public in accordance with Rule 37 of the Tribunal Procedure (Upper Tribunal) Rules 2008. It will be organised and conducted using Cloud Video Platform (CVP). Any media representative or any other member of the public wishing to witness the hearing will need to do so over the internet and provide an email address at which to be sent an appropriate link for access. Please contact UTAACMailbox@justice.gov.uk." + }, + openingStatementTitle: "Important information", + searchCasesTitle: "Search Cases", + searchCasesLabel: "Search by case reference, appellant, case name, judge, venue, or other details", + tableHeaders: { + time: "Time", + appellant: "Appellant", + caseReferenceNumber: "Case reference number", + judges: "Judge(s)", + members: "Member(s)", + modeOfHearing: "Mode of hearing", + venue: "Venue", + additionalInformation: "Additional information" + }, + dataSource: "Data source", + backToTop: "Back to top", + cautionNote: + "Note this document contains Special Category Data as defined by Data Protection Act 2018, formally known as Sensitive Personal Data, and should be handled appropriately.", + cautionReporting: + "This document contains information intended to assist the accurate reporting of court proceedings. It is vital you ensure that you safeguard the Special Category Data included and abide by reporting restrictions (for example on victims and children). HMCTS will stop sending the data if there is concern about how it will be used.", + provenanceLabels: { + MANUAL_UPLOAD: "Manual Upload", + XHIBIT: "XHIBIT", + SNL: "SNL", + COMMON_PLATFORM: "Common Platform" + } +}; diff --git a/libs/list-types/upper-tribunal-administrative-appeals-chamber-daily-hearing-list/src/models/types.ts b/libs/list-types/upper-tribunal-administrative-appeals-chamber-daily-hearing-list/src/models/types.ts new file mode 100644 index 000000000..0c63c5963 --- /dev/null +++ b/libs/list-types/upper-tribunal-administrative-appeals-chamber-daily-hearing-list/src/models/types.ts @@ -0,0 +1,12 @@ +export interface UtaacHearing { + time: string; + appellant: string; + caseReferenceNumber: string; + judges: string; + members: string; + modeOfHearing: string; + venue: string; + additionalInformation: string; +} + +export type UtaacHearingList = UtaacHearing[]; diff --git a/libs/list-types/upper-tribunal-administrative-appeals-chamber-daily-hearing-list/src/pdf/pdf-generator.test.ts b/libs/list-types/upper-tribunal-administrative-appeals-chamber-daily-hearing-list/src/pdf/pdf-generator.test.ts new file mode 100644 index 000000000..ce28fe749 --- /dev/null +++ b/libs/list-types/upper-tribunal-administrative-appeals-chamber-daily-hearing-list/src/pdf/pdf-generator.test.ts @@ -0,0 +1,162 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { mockUploadBlob } = vi.hoisted(() => ({ + mockUploadBlob: vi.fn() +})); + +vi.mock("@hmcts/azure-blob", () => ({ + uploadBlob: mockUploadBlob, + CONTAINER: { ARTEFACT: "artefact", PUBLICATIONS: "publications" } +})); + +vi.mock("@hmcts/pdf-generation", () => ({ + generatePdfFromHtml: vi.fn() +})); + +vi.mock("../rendering/renderer.js", () => ({ + renderUtaacDailyHearingListData: vi.fn() +})); + +import { generatePdfFromHtml } from "@hmcts/pdf-generation"; +import { renderUtaacDailyHearingListData } from "../rendering/renderer.js"; +import { generateUtaacDailyHearingListPdf } from "./pdf-generator.js"; + +const mockRenderedData = { + header: { + listTitle: "Upper Tribunal (Administrative Appeals Chamber) Daily Hearing List", + hearingDate: "15 January 2025", + lastUpdatedDate: "15 January 2025", + lastUpdatedTime: "9am" + }, + hearings: [] +}; + +const mockHearingList = [ + { + time: "10:00am", + appellant: "Smith", + caseReferenceNumber: "UTAAC/2025/0001", + judges: "Judge Smith", + members: "Member Jones", + modeOfHearing: "CVP", + venue: "Field House", + additionalInformation: "" + } +]; + +describe("generateUtaacDailyHearingListPdf", () => { + beforeEach(() => { + vi.clearAllMocks(); + + vi.mocked(renderUtaacDailyHearingListData).mockReturnValue(mockRenderedData); + mockUploadBlob.mockResolvedValue(undefined); + }); + + it("should generate PDF successfully", async () => { + const pdfBuffer = Buffer.from("PDF content"); + vi.mocked(generatePdfFromHtml).mockResolvedValue({ + success: true, + pdfBuffer, + sizeBytes: 1024 + }); + + const result = await generateUtaacDailyHearingListPdf({ + artefactId: "test-artefact-123", + contentDate: new Date("2025-01-15"), + locale: "en", + locationId: "240", + jsonData: mockHearingList + }); + + expect(result.success).toBe(true); + expect(result.pdfPath).toContain("test-artefact-123.pdf"); + expect(result.sizeBytes).toBe(1024); + expect(result.exceedsMaxSize).toBe(false); + }); + + it("should return exceedsMaxSize true when PDF is over 2MB", async () => { + const largePdfBuffer = Buffer.alloc(3 * 1024 * 1024); + vi.mocked(generatePdfFromHtml).mockResolvedValue({ + success: true, + pdfBuffer: largePdfBuffer, + sizeBytes: 3 * 1024 * 1024 + }); + + const result = await generateUtaacDailyHearingListPdf({ + artefactId: "large-pdf-123", + contentDate: new Date("2025-01-15"), + locale: "en", + locationId: "240", + jsonData: mockHearingList + }); + + expect(result.success).toBe(true); + expect(result.exceedsMaxSize).toBe(true); + }); + + it("should return error when PDF generation fails", async () => { + vi.mocked(generatePdfFromHtml).mockResolvedValue({ + success: false, + error: "Puppeteer crashed" + }); + + const result = await generateUtaacDailyHearingListPdf({ + artefactId: "failed-pdf", + contentDate: new Date("2025-01-15"), + locale: "en", + locationId: "240", + jsonData: mockHearingList + }); + + expect(result.success).toBe(false); + expect(result.error).toBe("Puppeteer crashed"); + }); + + it("should return error when PDF generation reports success but returns no buffer", async () => { + // Arrange + vi.mocked(generatePdfFromHtml).mockResolvedValue({ + success: true, + pdfBuffer: undefined, + sizeBytes: undefined + }); + + // Act + const result = await generateUtaacDailyHearingListPdf({ + artefactId: "no-buffer-pdf", + contentDate: new Date("2025-01-15"), + locale: "en", + locationId: "240", + jsonData: mockHearingList + }); + + // Assert + expect(result.success).toBe(false); + expect(result.error).toBe("PDF generation failed"); + }); + + it("should pass correct render options to renderer", async () => { + vi.mocked(generatePdfFromHtml).mockResolvedValue({ + success: true, + pdfBuffer: Buffer.from("PDF"), + sizeBytes: 100 + }); + + const contentDate = new Date("2025-01-15"); + + await generateUtaacDailyHearingListPdf({ + artefactId: "test-render-options", + contentDate, + locale: "cy", + locationId: "999", + jsonData: mockHearingList + }); + + expect(renderUtaacDailyHearingListData).toHaveBeenCalledWith(mockHearingList, { + locale: "cy", + courtName: "Upper Tribunal (Administrative Appeals Chamber)", + contentDate, + lastReceivedDate: expect.any(String), + listTitle: "Upper Tribunal (Administrative Appeals Chamber) Daily Hearing List" + }); + }); +}); diff --git a/libs/list-types/upper-tribunal-administrative-appeals-chamber-daily-hearing-list/src/pdf/pdf-generator.ts b/libs/list-types/upper-tribunal-administrative-appeals-chamber-daily-hearing-list/src/pdf/pdf-generator.ts new file mode 100644 index 000000000..6933593a5 --- /dev/null +++ b/libs/list-types/upper-tribunal-administrative-appeals-chamber-daily-hearing-list/src/pdf/pdf-generator.ts @@ -0,0 +1,20 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { generatePdfFromHtml } from "@hmcts/pdf-generation"; +import { PROVENANCE_LABELS } from "@hmcts/publication"; +import { createUtDailyHearingListPdfGenerator } from "@hmcts/upper-tribunal-common"; +import type { UtaacHearingList } from "../models/types.js"; +import { renderUtaacDailyHearingListData } from "../rendering/renderer.js"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +export const generateUtaacDailyHearingListPdf = createUtDailyHearingListPdfGenerator( + "Upper Tribunal (Administrative Appeals Chamber)", + "Upper Tribunal (Administrative Appeals Chamber) Daily Hearing List", + renderUtaacDailyHearingListData, + () => import("../locales/en.js"), + () => import("../locales/cy.js"), + __dirname, + PROVENANCE_LABELS, + generatePdfFromHtml +); diff --git a/libs/list-types/upper-tribunal-administrative-appeals-chamber-daily-hearing-list/src/pdf/pdf-template.njk b/libs/list-types/upper-tribunal-administrative-appeals-chamber-daily-hearing-list/src/pdf/pdf-template.njk new file mode 100644 index 000000000..67184f8a4 --- /dev/null +++ b/libs/list-types/upper-tribunal-administrative-appeals-chamber-daily-hearing-list/src/pdf/pdf-template.njk @@ -0,0 +1,80 @@ + + + + + + {{ header.listTitle }} + + + +
+

{{ header.listTitle }}

+ +

{{ t.factLinkText }} {{ t.factAdditionalText }}

+ +

{{ t.listForDate }} {{ header.hearingDate }}

+

{{ t.lastUpdated }} {{ header.lastUpdatedDate }} {{ t.at }} {{ header.lastUpdatedTime }}

+
+ +
+

{{ t.openingStatement.detailsTitle }}

+

{{ t.openingStatement.listChangeNotice }}

+ +

{{ t.openingStatement.englandAndWalesTitle }}

+

{{ t.openingStatement.englandAndWalesRemoteText }}

+

{{ t.openingStatement.englandAndWalesCvpText }}

+

{{ t.openingStatement.englandAndWalesAccessText }}

+

{{ t.openingStatement.englandAndWalesContactText }}

+ +

{{ t.openingStatement.scotlandTitle }}

+

{{ t.openingStatement.scotlandRemoteText }}

+

{{ t.openingStatement.scotlandCvpText }}

+
+ + {% if hearings.length > 0 %} + + + + + + + + + + + + + + + {% for hearing in hearings %} + + + + + + + + + + + {% endfor %} + +
{{ t.tableHeaders.time }}{{ t.tableHeaders.appellant }}{{ t.tableHeaders.caseReferenceNumber }}{{ t.tableHeaders.judges }}{{ t.tableHeaders.members }}{{ t.tableHeaders.modeOfHearing }}{{ t.tableHeaders.venue }}{{ t.tableHeaders.additionalInformation }}
{{ hearing.time }}{{ hearing.appellant }}{{ hearing.caseReferenceNumber }}{{ hearing.judges }}{{ hearing.members }}{{ hearing.modeOfHearing }}{{ hearing.venue }}{{ hearing.additionalInformation }}
+ {% else %} +

No hearings scheduled.

+ {% endif %} + + + + diff --git a/libs/list-types/upper-tribunal-administrative-appeals-chamber-daily-hearing-list/src/rendering/renderer.test.ts b/libs/list-types/upper-tribunal-administrative-appeals-chamber-daily-hearing-list/src/rendering/renderer.test.ts new file mode 100644 index 000000000..f519d25f4 --- /dev/null +++ b/libs/list-types/upper-tribunal-administrative-appeals-chamber-daily-hearing-list/src/rendering/renderer.test.ts @@ -0,0 +1,140 @@ +import { describe, expect, it } from "vitest"; +import type { UtaacHearingList } from "../models/types.js"; +import { renderUtaacDailyHearingListData } from "./renderer.js"; + +describe("renderUtaacDailyHearingListData", () => { + const baseOptions = { + locale: "en", + courtName: "Upper Tribunal (Administrative Appeals Chamber)", + contentDate: new Date(2025, 0, 15), + lastReceivedDate: "2025-01-15T09:55:00Z", + listTitle: "Upper Tribunal (Administrative Appeals Chamber) Daily Hearing List" + }; + + it("should render hearing list with header", () => { + const hearingList: UtaacHearingList = [ + { + time: "10:00am", + appellant: "Smith", + caseReferenceNumber: "UTAAC/2025/0001", + judges: "Judge Smith", + members: "Member Jones", + modeOfHearing: "CVP", + venue: "Field House", + additionalInformation: "Remote hearing" + } + ]; + + const result = renderUtaacDailyHearingListData(hearingList, baseOptions); + + expect(result.header.listTitle).toBe("Upper Tribunal (Administrative Appeals Chamber) Daily Hearing List"); + expect(result.header.hearingDate).toBe("15 January 2025"); + expect(result.header.lastUpdatedDate).toBe("15 January 2025"); + expect(result.header.lastUpdatedTime).toContain("am"); + }); + + it("should render multiple hearings", () => { + const hearingList: UtaacHearingList = [ + { + time: "10:00am", + appellant: "Smith", + caseReferenceNumber: "UTAAC/2025/0001", + judges: "Judge Smith", + members: "Member Jones", + modeOfHearing: "CVP", + venue: "Field House", + additionalInformation: "" + }, + { + time: "2:00pm", + appellant: "Brown", + caseReferenceNumber: "UTAAC/2025/0002", + judges: "Judge Brown", + members: "Member Davis", + modeOfHearing: "In person", + venue: "Field House", + additionalInformation: "" + } + ]; + + const result = renderUtaacDailyHearingListData(hearingList, baseOptions); + + expect(result.hearings).toHaveLength(2); + expect(result.hearings[0].caseReferenceNumber).toBe("UTAAC/2025/0001"); + expect(result.hearings[1].caseReferenceNumber).toBe("UTAAC/2025/0002"); + }); + + it("should include appellant and caseReferenceNumber in rendered data", () => { + const hearingList: UtaacHearingList = [ + { + time: "10:00am", + appellant: "Smith", + caseReferenceNumber: "UTAAC/2025/0001", + judges: "Judge Smith", + members: "Member Jones", + modeOfHearing: "CVP", + venue: "Field House", + additionalInformation: "" + } + ]; + + const result = renderUtaacDailyHearingListData(hearingList, baseOptions); + + expect(result.hearings[0].appellant).toBe("Smith"); + expect(result.hearings[0].caseReferenceNumber).toBe("UTAAC/2025/0001"); + }); + + it("should include additionalInformation in rendered data", () => { + const hearingList: UtaacHearingList = [ + { + time: "10:00am", + appellant: "Smith", + caseReferenceNumber: "UTAAC/2025/0001", + judges: "Judge Smith", + members: "Member Jones", + modeOfHearing: "CVP", + venue: "Field House", + additionalInformation: "Remote hearing" + } + ]; + + const result = renderUtaacDailyHearingListData(hearingList, baseOptions); + + expect(result.hearings[0].additionalInformation).toBe("Remote hearing"); + }); + + it("should handle empty hearing list", () => { + const hearingList: UtaacHearingList = []; + + const result = renderUtaacDailyHearingListData(hearingList, baseOptions); + + expect(result.hearings).toHaveLength(0); + expect(result.header.listTitle).toBe("Upper Tribunal (Administrative Appeals Chamber) Daily Hearing List"); + }); + + it("should preserve all hearing fields without modification", () => { + const hearingList: UtaacHearingList = [ + { + time: "10:30am", + appellant: "Smith & Jones", + caseReferenceNumber: "UTAAC/2025/0001/A", + judges: "Judge Smith; Judge Brown", + members: "Member Jones, Member Davis", + modeOfHearing: "BT Meet Me", + venue: "Field House, Room 3", + additionalInformation: "Contact adminappeals@justice.gov.uk for details" + } + ]; + + const result = renderUtaacDailyHearingListData(hearingList, baseOptions); + + expect(result.hearings[0].time).toBe("10:30am"); + expect(result.hearings[0].appellant).toBe("Smith & Jones"); + expect(result.hearings[0].caseReferenceNumber).toBe("UTAAC/2025/0001/A"); + expect(result.hearings[0].judges).toBe("Judge Smith; Judge Brown"); + expect(result.hearings[0].members).toBe("Member Jones, Member Davis"); + expect(result.hearings[0].modeOfHearing).toBe("BT Meet Me"); + expect(result.hearings[0].venue).toBe("Field House, Room 3"); + expect(result.hearings[0].additionalInformation).toBe("Contact adminappeals@justice.gov.uk for details"); + }); +}); diff --git a/libs/list-types/upper-tribunal-administrative-appeals-chamber-daily-hearing-list/src/rendering/renderer.ts b/libs/list-types/upper-tribunal-administrative-appeals-chamber-daily-hearing-list/src/rendering/renderer.ts new file mode 100644 index 000000000..ff56d161b --- /dev/null +++ b/libs/list-types/upper-tribunal-administrative-appeals-chamber-daily-hearing-list/src/rendering/renderer.ts @@ -0,0 +1,46 @@ +import { formatDisplayDate, formatLastUpdatedDateTime } from "@hmcts/list-types-common"; +import type { UtaacHearing, UtaacHearingList } from "../models/types.js"; + +export interface RenderOptions { + locale: string; + courtName: string; + contentDate: Date; + lastReceivedDate: string; + listTitle: string; +} + +export interface RenderedData { + header: { + listTitle: string; + hearingDate: string; + lastUpdatedDate: string; + lastUpdatedTime: string; + }; + hearings: UtaacHearing[]; +} + +export function renderUtaacDailyHearingListData(hearingList: UtaacHearingList, options: RenderOptions): RenderedData { + const hearingDate = formatDisplayDate(options.contentDate, options.locale); + const { date: lastUpdatedDate, time: lastUpdatedTime } = formatLastUpdatedDateTime(options.lastReceivedDate, options.locale); + + const renderedHearings = hearingList.map((hearing) => ({ + time: hearing.time, + appellant: hearing.appellant, + caseReferenceNumber: hearing.caseReferenceNumber, + judges: hearing.judges, + members: hearing.members, + modeOfHearing: hearing.modeOfHearing, + venue: hearing.venue, + additionalInformation: hearing.additionalInformation + })); + + return { + header: { + listTitle: options.listTitle, + hearingDate, + lastUpdatedDate, + lastUpdatedTime + }, + hearings: renderedHearings + }; +} diff --git a/libs/list-types/upper-tribunal-administrative-appeals-chamber-daily-hearing-list/src/schemas/upper-tribunal-administrative-appeals-chamber-daily-hearing-list.json b/libs/list-types/upper-tribunal-administrative-appeals-chamber-daily-hearing-list/src/schemas/upper-tribunal-administrative-appeals-chamber-daily-hearing-list.json new file mode 100644 index 000000000..8c4005ef8 --- /dev/null +++ b/libs/list-types/upper-tribunal-administrative-appeals-chamber-daily-hearing-list/src/schemas/upper-tribunal-administrative-appeals-chamber-daily-hearing-list.json @@ -0,0 +1,68 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Root", + "type": "array", + "items": { + "title": "Hearing list", + "type": "object", + "required": ["time", "appellant", "caseReferenceNumber", "judges", "members", "modeOfHearing", "venue", "additionalInformation"], + "properties": { + "time": { + "title": "Time", + "type": "string", + "default": "", + "examples": ["10am"], + "pattern": "^\\d{1,2}([:.]\\d{2})?[ap]m\\s*$" + }, + "appellant": { + "title": "Appellant", + "type": "string", + "default": "", + "examples": ["Appellant 1"], + "pattern": "^(?!(.|\\r|\\n)*<[^>]+>)(.|\\r|\\n)*$" + }, + "caseReferenceNumber": { + "title": "Case reference number", + "type": "string", + "default": "", + "examples": ["12345"], + "pattern": "^(?!(.|\\r|\\n)*<[^>]+>)(.|\\r|\\n)*$" + }, + "judges": { + "title": "Judges", + "type": "string", + "default": "", + "examples": ["Judge 1"], + "pattern": "^(?!(.|\\r|\\n)*<[^>]+>)(.|\\r|\\n)*$" + }, + "members": { + "title": "Members", + "type": "string", + "default": "", + "examples": ["Member 1"], + "pattern": "^(?!(.|\\r|\\n)*<[^>]+>)(.|\\r|\\n)*$" + }, + "modeOfHearing": { + "title": "Mode of Hearing", + "type": "string", + "default": "", + "examples": ["Hearing mode 1"], + "pattern": "^(?!(.|\\r|\\n)*<[^>]+>)(.|\\r|\\n)*$" + }, + "venue": { + "title": "Venue", + "type": "string", + "default": "", + "examples": ["Venue 1"], + "pattern": "^(?!(.|\\r|\\n)*<[^>]+>)(.|\\r|\\n)*$" + }, + "additionalInformation": { + "title": "Additional information", + "type": "string", + "default": "", + "examples": ["This is additional information"], + "pattern": "^(?!(.|\\r|\\n)*<[^>]+>)(.|\\r|\\n)*$" + } + } + } +} diff --git a/libs/list-types/upper-tribunal-administrative-appeals-chamber-daily-hearing-list/src/validation/json-validator.test.ts b/libs/list-types/upper-tribunal-administrative-appeals-chamber-daily-hearing-list/src/validation/json-validator.test.ts new file mode 100644 index 000000000..7bd6db969 --- /dev/null +++ b/libs/list-types/upper-tribunal-administrative-appeals-chamber-daily-hearing-list/src/validation/json-validator.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it, vi } from "vitest"; + +vi.mock("@hmcts/publication", () => ({ + validateJson: vi.fn() +})); + +import { validateJson } from "@hmcts/publication"; +import { validateUtAdministrativeAppealsChamberDailyHearingList } from "./json-validator.js"; + +describe("validateUtAdministrativeAppealsChamberDailyHearingList", () => { + it("should call validateJson with the provided data and schema and return the result", () => { + // Arrange + const mockData = { hearings: [] }; + const mockResult = { isValid: true, errors: [] }; + vi.mocked(validateJson).mockReturnValue(mockResult as any); + + // Act + const result = validateUtAdministrativeAppealsChamberDailyHearingList(mockData); + + // Assert + expect(validateJson).toHaveBeenCalledWith(mockData, expect.any(Object), "1.0"); + expect(result).toBe(mockResult); + }); + + it("should return the validation result when data is invalid", () => { + // Arrange + const mockData = { invalid: true }; + const mockResult = { isValid: false, errors: ["Missing required field"] }; + vi.mocked(validateJson).mockReturnValue(mockResult as any); + + // Act + const result = validateUtAdministrativeAppealsChamberDailyHearingList(mockData); + + // Assert + expect(validateJson).toHaveBeenCalledWith(mockData, expect.any(Object), "1.0"); + expect(result).toBe(mockResult); + }); +}); diff --git a/libs/list-types/upper-tribunal-administrative-appeals-chamber-daily-hearing-list/src/validation/json-validator.ts b/libs/list-types/upper-tribunal-administrative-appeals-chamber-daily-hearing-list/src/validation/json-validator.ts new file mode 100644 index 000000000..59a30ff9e --- /dev/null +++ b/libs/list-types/upper-tribunal-administrative-appeals-chamber-daily-hearing-list/src/validation/json-validator.ts @@ -0,0 +1,6 @@ +import { type ValidationResult, validateJson } from "@hmcts/publication"; +import schema from "../schemas/upper-tribunal-administrative-appeals-chamber-daily-hearing-list.json" with { type: "json" }; + +export function validateUtAdministrativeAppealsChamberDailyHearingList(jsonData: unknown): ValidationResult { + return validateJson(jsonData, schema, "1.0"); +} diff --git a/libs/list-types/upper-tribunal-administrative-appeals-chamber-daily-hearing-list/tsconfig.json b/libs/list-types/upper-tribunal-administrative-appeals-chamber-daily-hearing-list/tsconfig.json new file mode 100644 index 000000000..a029ee937 --- /dev/null +++ b/libs/list-types/upper-tribunal-administrative-appeals-chamber-daily-hearing-list/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "declaration": true, + "declarationMap": true, + "resolveJsonModule": true + }, + "include": ["src/**/*"], + "exclude": ["**/*.test.ts", "**/*.spec.ts", "dist", "node_modules", "src/assets/"] +} diff --git a/libs/list-types/upper-tribunal-common/package.json b/libs/list-types/upper-tribunal-common/package.json new file mode 100644 index 000000000..4edef1bb7 --- /dev/null +++ b/libs/list-types/upper-tribunal-common/package.json @@ -0,0 +1,31 @@ +{ + "name": "@hmcts/upper-tribunal-common", + "version": "1.0.0", + "type": "module", + "exports": { + ".": { + "production": "./dist/index.js", + "default": "./src/index.ts" + } + }, + "scripts": { + "build": "tsc", + "dev": "tsc --watch", + "test": "vitest run", + "test:watch": "vitest watch", + "format": "biome format --write .", + "lint": "biome check .", + "lint:fix": "biome check --write --unsafe ." + }, + "dependencies": { + "@hmcts/azure-blob": "workspace:*", + "@hmcts/list-types-common": "workspace:*", + "@hmcts/pdf-generation": "workspace:*", + "nunjucks": "3.2.4" + }, + "devDependencies": { + "@types/node": "24.10.4", + "typescript": "6.0.3", + "vitest": "4.1.9" + } +} diff --git a/libs/list-types/upper-tribunal-common/src/index.ts b/libs/list-types/upper-tribunal-common/src/index.ts new file mode 100644 index 000000000..3db39addc --- /dev/null +++ b/libs/list-types/upper-tribunal-common/src/index.ts @@ -0,0 +1 @@ +export { createUtDailyHearingListPdfGenerator, type DailyHearingListRenderedData, type DailyHearingListRenderOptions } from "./pdf-generator.js"; diff --git a/libs/list-types/upper-tribunal-common/src/pdf-generator.test.ts b/libs/list-types/upper-tribunal-common/src/pdf-generator.test.ts new file mode 100644 index 000000000..9279f9b30 --- /dev/null +++ b/libs/list-types/upper-tribunal-common/src/pdf-generator.test.ts @@ -0,0 +1,167 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { mockUploadBlob } = vi.hoisted(() => ({ + mockUploadBlob: vi.fn() +})); + +vi.mock("@hmcts/azure-blob", () => ({ + uploadBlob: mockUploadBlob, + CONTAINER: { ARTEFACT: "artefact", PUBLICATIONS: "publications" } +})); + +vi.mock("nunjucks", () => ({ + default: { + configure: vi.fn().mockReturnValue({ + render: vi.fn().mockReturnValue("mock"), + renderString: vi.fn().mockReturnValue("mock") + }) + } +})); + +import { createUtDailyHearingListPdfGenerator } from "./pdf-generator.js"; + +describe("createUtDailyHearingListPdfGenerator", () => { + const mockRenderFn = vi.fn(); + const mockImportEn = vi.fn().mockResolvedValue({ en: { pageTitle: "English" } }); + const mockImportCy = vi.fn().mockResolvedValue({ cy: { pageTitle: "Welsh" } }); + const mockGeneratePdfFn = vi.fn(); + const mockProvenanceLabels = { MANUAL_UPLOAD: "Manual Upload" }; + + const mockRenderedData = { + header: { listTitle: "Test List", hearingDate: "1 Jan 2026", lastUpdatedDate: "1 Jan 2026", lastUpdatedTime: "12pm" }, + hearings: [{ time: "10:00", caseReference: "REF/001", caseName: "Test v Test" }] + }; + + const baseOptions = { + artefactId: "artefact-123", + locale: "en", + locationId: "1", + jsonData: [{ time: "10:00", caseReference: "REF/001", caseName: "Test v Test" }], + contentDate: new Date("2026-01-01") + }; + + beforeEach(() => { + vi.clearAllMocks(); + mockRenderFn.mockReturnValue(mockRenderedData); + mockImportEn.mockResolvedValue({ en: { pageTitle: "English" } }); + mockImportCy.mockResolvedValue({ cy: { pageTitle: "Welsh" } }); + mockUploadBlob.mockResolvedValue(undefined); + }); + + const makeGenerator = () => + createUtDailyHearingListPdfGenerator( + "Test Court", + "Test List Title", + mockRenderFn, + mockImportEn, + mockImportCy, + "/fake/dirname", + mockProvenanceLabels, + mockGeneratePdfFn + ); + + it("should generate and store PDF successfully", async () => { + // Arrange + const pdfBuffer = Buffer.from("PDF content"); + mockGeneratePdfFn.mockResolvedValue({ success: true, pdfBuffer, sizeBytes: 1024 }); + + // Act + const result = await makeGenerator()(baseOptions); + + // Assert + expect(result.success).toBe(true); + expect(result.pdfPath).toContain("artefact-123.pdf"); + expect(mockRenderFn).toHaveBeenCalledWith( + baseOptions.jsonData, + expect.objectContaining({ courtName: "Test Court", listTitle: "Test List Title", locale: "en" }) + ); + }); + + it("should use Welsh translations when locale is cy", async () => { + // Arrange + const pdfBuffer = Buffer.from("PDF content"); + mockGeneratePdfFn.mockResolvedValue({ success: true, pdfBuffer, sizeBytes: 512 }); + + // Act + await makeGenerator()({ ...baseOptions, locale: "cy" }); + + // Assert + expect(mockImportCy).toHaveBeenCalled(); + expect(mockImportEn).not.toHaveBeenCalled(); + }); + + it("should resolve known provenance label", async () => { + // Arrange + const pdfBuffer = Buffer.from("PDF"); + mockGeneratePdfFn.mockResolvedValue({ success: true, pdfBuffer, sizeBytes: 100 }); + + // Act + await makeGenerator()({ ...baseOptions, provenance: "MANUAL_UPLOAD" }); + + // Assert — generatePdfFn receives html containing the resolved label + expect(mockGeneratePdfFn).toHaveBeenCalledWith(expect.any(String)); + }); + + it("should fall back to raw provenance value when not in labels", async () => { + // Arrange + const pdfBuffer = Buffer.from("PDF"); + mockGeneratePdfFn.mockResolvedValue({ success: true, pdfBuffer, sizeBytes: 100 }); + + // Act — no error expected + const result = await makeGenerator()({ ...baseOptions, provenance: "UNKNOWN_SOURCE" }); + + // Assert + expect(result.success).toBe(true); + }); + + it("should return empty provenance label when provenance is undefined", async () => { + // Arrange + const pdfBuffer = Buffer.from("PDF"); + mockGeneratePdfFn.mockResolvedValue({ success: true, pdfBuffer, sizeBytes: 100 }); + const { provenance: _omit, ...optionsWithoutProvenance } = baseOptions as typeof baseOptions & { provenance?: string }; + + // Act + const result = await makeGenerator()(optionsWithoutProvenance); + + // Assert + expect(result.success).toBe(true); + }); + + it("should return failure when generatePdfFn returns success false", async () => { + // Arrange + mockGeneratePdfFn.mockResolvedValue({ success: false, error: "Puppeteer failed" }); + + // Act + const result = await makeGenerator()(baseOptions); + + // Assert + expect(result.success).toBe(false); + expect(result.error).toBe("Puppeteer failed"); + }); + + it("should return failure when generatePdfFn returns success true but no pdfBuffer", async () => { + // Arrange + mockGeneratePdfFn.mockResolvedValue({ success: true, pdfBuffer: undefined }); + + // Act + const result = await makeGenerator()(baseOptions); + + // Assert + expect(result.success).toBe(false); + expect(result.error).toBe("PDF generation failed"); + }); + + it("should return error result when renderFn throws", async () => { + // Arrange + mockRenderFn.mockImplementation(() => { + throw new Error("Render exploded"); + }); + + // Act + const result = await makeGenerator()(baseOptions); + + // Assert + expect(result.success).toBe(false); + expect(result.error).toContain("Render exploded"); + }); +}); diff --git a/libs/list-types/upper-tribunal-common/src/pdf-generator.ts b/libs/list-types/upper-tribunal-common/src/pdf-generator.ts new file mode 100644 index 000000000..a60a42cf0 --- /dev/null +++ b/libs/list-types/upper-tribunal-common/src/pdf-generator.ts @@ -0,0 +1,69 @@ +import { + type BasePdfGenerationOptions, + configureNunjucks, + createPdfErrorResult, + loadTranslations, + PDF_BASE_STYLES, + type PdfFromHtmlResult, + type PdfGenerationResult, + savePdfToStorage +} from "@hmcts/list-types-common"; + +export interface DailyHearingListRenderOptions { + locale: string; + courtName: string; + contentDate: Date; + lastReceivedDate: string; + listTitle: string; +} + +export interface DailyHearingListRenderedData { + header: { listTitle: string; hearingDate: string; lastUpdatedDate: string; lastUpdatedTime: string }; + hearings: unknown[]; +} + +export function createUtDailyHearingListPdfGenerator( + courtName: string, + listTitle: string, + renderFn: (data: T, options: DailyHearingListRenderOptions) => DailyHearingListRenderedData, + importEn: () => Promise<{ en: Record }>, + importCy: () => Promise<{ cy: Record }>, + dirname: string, + provenanceLabels: Record, + generatePdfFn: (html: string) => Promise +) { + return async function generatePdf(options: BasePdfGenerationOptions & { contentDate: Date }): Promise { + try { + const renderedData = renderFn(options.jsonData, { + locale: options.locale, + courtName, + contentDate: options.contentDate, + lastReceivedDate: new Date().toISOString(), + listTitle + }); + + const translations = await loadTranslations(options.locale, importEn, importCy); + + const provenanceLabel = options.provenance ? provenanceLabels[options.provenance] || options.provenance : ""; + + const env = configureNunjucks(dirname); + const html = env.render("pdf-template.njk", { + header: renderedData.header, + hearings: renderedData.hearings, + dataSource: provenanceLabel, + t: translations, + pdfStyles: PDF_BASE_STYLES + }); + + const pdfResult = await generatePdfFn(html); + + if (!pdfResult.success || !pdfResult.pdfBuffer) { + return { success: false, error: pdfResult.error || "PDF generation failed" }; + } + + return await savePdfToStorage(options.artefactId, pdfResult.pdfBuffer, pdfResult.sizeBytes!); + } catch (error) { + return createPdfErrorResult(error); + } + }; +} diff --git a/libs/list-types/upper-tribunal-common/tsconfig.json b/libs/list-types/upper-tribunal-common/tsconfig.json new file mode 100644 index 000000000..5a1c59625 --- /dev/null +++ b/libs/list-types/upper-tribunal-common/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "declaration": true, + "declarationMap": true + }, + "include": ["src/**/*"], + "exclude": ["**/*.test.ts", "**/*.spec.ts", "dist", "node_modules"] +} diff --git a/libs/list-types/upper-tribunal-lands-chamber-daily-hearing-list/package.json b/libs/list-types/upper-tribunal-lands-chamber-daily-hearing-list/package.json new file mode 100644 index 000000000..c4c7dded6 --- /dev/null +++ b/libs/list-types/upper-tribunal-lands-chamber-daily-hearing-list/package.json @@ -0,0 +1,42 @@ +{ + "name": "@hmcts/upper-tribunal-lands-chamber-daily-hearing-list", + "version": "1.0.0", + "type": "module", + "exports": { + ".": { + "production": "./dist/index.js", + "default": "./src/index.ts" + }, + "./config": { + "production": "./dist/config.js", + "default": "./src/config.ts" + } + }, + "scripts": { + "build": "tsc && yarn build:nunjucks && yarn build:schemas", + "build:nunjucks": "mkdir -p dist/pdf && cd src/pdf && find . -name '*.njk' -exec sh -c 'mkdir -p ../../dist/pdf/$(dirname {}) && cp {} ../../dist/pdf/{}' \\;", + "build:schemas": "mkdir -p dist/schemas && cp src/schemas/*.json dist/schemas/", + "dev": "tsc --watch", + "test": "vitest run", + "test:watch": "vitest watch", + "format": "biome format --write .", + "lint": "biome check .", + "lint:fix": "biome check --write --unsafe ." + }, + "dependencies": { + "@hmcts/list-types-common": "workspace:*", + "@hmcts/pdf-generation": "workspace:*", + "@hmcts/postgres-prisma": "workspace:*", + "@hmcts/upper-tribunal-common": "workspace:*", + "luxon": "3.7.2" + }, + "devDependencies": { + "@types/luxon": "3.7.1", + "@types/node": "24.10.4", + "typescript": "6.0.3", + "vitest": "4.1.8" + }, + "peerDependencies": { + "express": "^5.1.0" + } +} diff --git a/libs/list-types/upper-tribunal-lands-chamber-daily-hearing-list/src/config.test.ts b/libs/list-types/upper-tribunal-lands-chamber-daily-hearing-list/src/config.test.ts new file mode 100644 index 000000000..cc7d90855 --- /dev/null +++ b/libs/list-types/upper-tribunal-lands-chamber-daily-hearing-list/src/config.test.ts @@ -0,0 +1,67 @@ +import { existsSync } from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { assets, moduleRoot, schemaPath } from "./config.js"; + +describe("upper-tribunal-lands-chamber-daily-hearing-list config", () => { + describe("moduleRoot", () => { + it("should be defined", () => { + expect(moduleRoot).toBeDefined(); + expect(typeof moduleRoot).toBe("string"); + }); + + it("should point to an existing directory", () => { + expect(existsSync(moduleRoot)).toBe(true); + }); + + it("should be an absolute path", () => { + expect(path.isAbsolute(moduleRoot)).toBe(true); + }); + }); + + describe("schemaPath", () => { + it("should be defined", () => { + expect(schemaPath).toBeDefined(); + expect(typeof schemaPath).toBe("string"); + }); + + it("should point to the schema file", () => { + expect(schemaPath).toContain("upper-tribunal-lands-chamber-daily-hearing-list.json"); + }); + + it("should be an absolute path", () => { + expect(path.isAbsolute(schemaPath)).toBe(true); + }); + + it("should point to existing schema file", () => { + expect(existsSync(schemaPath)).toBe(true); + }); + }); + + describe("assets", () => { + it("should be defined", () => { + expect(assets).toBeDefined(); + expect(typeof assets).toBe("string"); + }); + + it("should point to assets directory", () => { + expect(assets).toContain("assets"); + }); + + it("should be an absolute path", () => { + expect(path.isAbsolute(assets)).toBe(true); + }); + + it("should have valid path structure", () => { + expect(assets).toBeTruthy(); + }); + + it("should end with trailing slash", () => { + expect(assets).toMatch(/\/$/); + }); + + it("should be subdirectory of moduleRoot", () => { + expect(assets.startsWith(moduleRoot)).toBe(true); + }); + }); +}); diff --git a/libs/list-types/upper-tribunal-lands-chamber-daily-hearing-list/src/config.ts b/libs/list-types/upper-tribunal-lands-chamber-daily-hearing-list/src/config.ts new file mode 100644 index 000000000..449f31ea3 --- /dev/null +++ b/libs/list-types/upper-tribunal-lands-chamber-daily-hearing-list/src/config.ts @@ -0,0 +1,9 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +export const moduleRoot = __dirname; +export const assets = path.join(__dirname, "assets/"); +export const schemaPath = path.join(__dirname, "schemas/upper-tribunal-lands-chamber-daily-hearing-list.json"); diff --git a/libs/list-types/upper-tribunal-lands-chamber-daily-hearing-list/src/conversion/utlc-config.test.ts b/libs/list-types/upper-tribunal-lands-chamber-daily-hearing-list/src/conversion/utlc-config.test.ts new file mode 100644 index 000000000..2e46275c1 --- /dev/null +++ b/libs/list-types/upper-tribunal-lands-chamber-daily-hearing-list/src/conversion/utlc-config.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "vitest"; +import { UTLC_EXCEL_CONFIG } from "./utlc-config.js"; + +describe("UTLC_EXCEL_CONFIG", () => { + it("should have correct configuration structure", () => { + expect(UTLC_EXCEL_CONFIG).toBeDefined(); + expect(UTLC_EXCEL_CONFIG.fields).toHaveLength(9); + expect(UTLC_EXCEL_CONFIG.minRows).toBe(1); + }); + + it("should have all expected field names", () => { + const fieldNames = UTLC_EXCEL_CONFIG.fields.map((f) => f.fieldName); + expect(fieldNames).toEqual([ + "time", + "caseReferenceNumber", + "caseName", + "judges", + "members", + "hearingType", + "venue", + "modeOfHearing", + "additionalInformation" + ]); + }); + + it("should have correct required flags", () => { + const requiredFields = UTLC_EXCEL_CONFIG.fields.filter((f) => f.required).map((f) => f.fieldName); + const optionalFields = UTLC_EXCEL_CONFIG.fields.filter((f) => !f.required).map((f) => f.fieldName); + + expect(requiredFields).toEqual([ + "time", + "caseReferenceNumber", + "caseName", + "judges", + "members", + "hearingType", + "venue", + "modeOfHearing", + "additionalInformation" + ]); + expect(optionalFields).toEqual([]); + }); + + describe("field validators", () => { + it("should accept values without HTML tags", () => { + for (const field of UTLC_EXCEL_CONFIG.fields) { + expect(() => field.validators?.[0]("Plain text value", 1)).not.toThrow(); + } + }); + + it("should reject values containing HTML tags", () => { + for (const field of UTLC_EXCEL_CONFIG.fields) { + expect(() => field.validators?.[0]("bold", 1)).toThrow(); + } + }); + }); +}); diff --git a/libs/list-types/upper-tribunal-lands-chamber-daily-hearing-list/src/conversion/utlc-config.ts b/libs/list-types/upper-tribunal-lands-chamber-daily-hearing-list/src/conversion/utlc-config.ts new file mode 100644 index 000000000..f9ac02881 --- /dev/null +++ b/libs/list-types/upper-tribunal-lands-chamber-daily-hearing-list/src/conversion/utlc-config.ts @@ -0,0 +1,64 @@ +import { createConverter, type ExcelConverterConfig, registerConverterByName, validateNoHtmlTags } from "@hmcts/list-types-common"; + +export const UTLC_EXCEL_CONFIG: ExcelConverterConfig = { + fields: [ + { + header: "Time", + fieldName: "time", + required: true, + validators: [(value, rowNumber) => validateNoHtmlTags(value, "Time", rowNumber)] + }, + { + header: "Case Reference Number", + fieldName: "caseReferenceNumber", + required: true, + validators: [(value, rowNumber) => validateNoHtmlTags(value, "Case Reference Number", rowNumber)] + }, + { + header: "Case Name", + fieldName: "caseName", + required: true, + validators: [(value, rowNumber) => validateNoHtmlTags(value, "Case Name", rowNumber)] + }, + { + header: "Judge(s)", + fieldName: "judges", + required: true, + validators: [(value, rowNumber) => validateNoHtmlTags(value, "Judge(s)", rowNumber)] + }, + { + header: "Member(s)", + fieldName: "members", + required: true, + validators: [(value, rowNumber) => validateNoHtmlTags(value, "Member(s)", rowNumber)] + }, + { + header: "Hearing Type", + fieldName: "hearingType", + required: true, + validators: [(value, rowNumber) => validateNoHtmlTags(value, "Hearing Type", rowNumber)] + }, + { + header: "Venue", + fieldName: "venue", + required: true, + validators: [(value, rowNumber) => validateNoHtmlTags(value, "Venue", rowNumber)] + }, + { + header: "Mode of Hearing", + fieldName: "modeOfHearing", + required: true, + validators: [(value, rowNumber) => validateNoHtmlTags(value, "Mode of Hearing", rowNumber)] + }, + { + header: "Additional Information", + fieldName: "additionalInformation", + required: true, + validators: [(value, rowNumber) => validateNoHtmlTags(value, "Additional Information", rowNumber)] + } + ], + minRows: 1 +}; + +const utlcConverter = createConverter(UTLC_EXCEL_CONFIG); +registerConverterByName("UT_LANDS_CHAMBER_DAILY_HEARING_LIST", utlcConverter); diff --git a/libs/list-types/upper-tribunal-lands-chamber-daily-hearing-list/src/email-summary/summary-builder.test.ts b/libs/list-types/upper-tribunal-lands-chamber-daily-hearing-list/src/email-summary/summary-builder.test.ts new file mode 100644 index 000000000..74229bd47 --- /dev/null +++ b/libs/list-types/upper-tribunal-lands-chamber-daily-hearing-list/src/email-summary/summary-builder.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from "vitest"; +import type { UtlcHearingList } from "../models/types.js"; +import { extractCaseSummary, formatCaseSummaryForEmail, SPECIAL_CATEGORY_DATA_WARNING } from "./summary-builder.js"; + +describe("SPECIAL_CATEGORY_DATA_WARNING", () => { + it("should contain required warning text", () => { + expect(SPECIAL_CATEGORY_DATA_WARNING).toContain("Special Category Data"); + expect(SPECIAL_CATEGORY_DATA_WARNING).toContain("Data Protection Act 2018"); + }); +}); + +describe("extractCaseSummary", () => { + it("should extract case summaries from hearing list", () => { + const hearingList: UtlcHearingList = [ + { + time: "10:00am", + caseReferenceNumber: "LC/2025/0001", + caseName: "Smith v Jones", + judges: "Judge Smith", + members: "Member Jones", + hearingType: "Substantive hearing", + venue: "Royal Courts of Justice", + modeOfHearing: "CVP" + }, + { + time: "2:00pm", + caseReferenceNumber: "LC/2025/0002", + caseName: "Brown v Green", + judges: "Judge Brown", + members: "Member Davis", + hearingType: "Preliminary hearing", + venue: "Royal Courts of Justice", + modeOfHearing: "In person" + } + ]; + + const result = extractCaseSummary(hearingList); + + expect(result).toHaveLength(2); + expect(result[0]).toEqual([ + { label: "Time", value: "10:00am" }, + { label: "Case reference number", value: "LC/2025/0001" }, + { label: "Case name", value: "Smith v Jones" } + ]); + expect(result[1]).toEqual([ + { label: "Time", value: "2:00pm" }, + { label: "Case reference number", value: "LC/2025/0002" }, + { label: "Case name", value: "Brown v Green" } + ]); + }); + + it("should handle empty hearing list", () => { + const hearingList: UtlcHearingList = []; + + const result = extractCaseSummary(hearingList); + + expect(result).toHaveLength(0); + }); + + it("should handle missing values with empty string", () => { + const hearingList: UtlcHearingList = [ + { + time: "", + caseReferenceNumber: "", + caseName: "", + judges: "", + members: "", + hearingType: "", + venue: "", + modeOfHearing: "" + } + ]; + + const result = extractCaseSummary(hearingList); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual([ + { label: "Time", value: "" }, + { label: "Case reference number", value: "" }, + { label: "Case name", value: "" } + ]); + }); +}); + +describe("formatCaseSummaryForEmail", () => { + it("should format single case summary correctly", () => { + const items = [ + [ + { label: "Time", value: "10:00am" }, + { label: "Case reference number", value: "LC/2025/0001" }, + { label: "Case name", value: "Smith v Jones" } + ] + ]; + + const result = formatCaseSummaryForEmail(items); + + expect(result).toContain("---"); + expect(result).toContain("Time - 10:00am"); + expect(result).toContain("Case reference number - LC/2025/0001"); + expect(result).toContain("Case name - Smith v Jones"); + }); + + it("should handle empty case list", () => { + const result = formatCaseSummaryForEmail([]); + + expect(result).toBe("No cases scheduled."); + }); +}); diff --git a/libs/list-types/upper-tribunal-lands-chamber-daily-hearing-list/src/email-summary/summary-builder.ts b/libs/list-types/upper-tribunal-lands-chamber-daily-hearing-list/src/email-summary/summary-builder.ts new file mode 100644 index 000000000..e9d365839 --- /dev/null +++ b/libs/list-types/upper-tribunal-lands-chamber-daily-hearing-list/src/email-summary/summary-builder.ts @@ -0,0 +1,12 @@ +import { type CaseSummary, formatCaseSummaryForEmail, SPECIAL_CATEGORY_DATA_WARNING } from "@hmcts/list-types-common"; +import type { UtlcHearingList } from "../models/types.js"; + +export { formatCaseSummaryForEmail, SPECIAL_CATEGORY_DATA_WARNING }; + +export function extractCaseSummary(jsonData: UtlcHearingList): CaseSummary[] { + return jsonData.map((hearing) => [ + { label: "Time", value: hearing.time || "" }, + { label: "Case reference number", value: hearing.caseReferenceNumber || "" }, + { label: "Case name", value: hearing.caseName || "" } + ]); +} diff --git a/libs/list-types/upper-tribunal-lands-chamber-daily-hearing-list/src/index.ts b/libs/list-types/upper-tribunal-lands-chamber-daily-hearing-list/src/index.ts new file mode 100644 index 000000000..688868fe4 --- /dev/null +++ b/libs/list-types/upper-tribunal-lands-chamber-daily-hearing-list/src/index.ts @@ -0,0 +1,12 @@ +import "./conversion/utlc-config.js"; // Register converter on module load + +// Business logic exports +export type { ValidationResult } from "@hmcts/publication"; +export * from "./email-summary/summary-builder.js"; +export { cy as upperTribunalLandsChamberDailyHearingListCy } from "./locales/cy.js"; +// Locale exports +export { en as upperTribunalLandsChamberDailyHearingListEn } from "./locales/en.js"; +export * from "./models/types.js"; +export * from "./pdf/pdf-generator.js"; +export * from "./rendering/renderer.js"; +export { validateUtLandsChamberDailyHearingList } from "./validation/json-validator.js"; diff --git a/libs/list-types/upper-tribunal-lands-chamber-daily-hearing-list/src/locales/cy.ts b/libs/list-types/upper-tribunal-lands-chamber-daily-hearing-list/src/locales/cy.ts new file mode 100644 index 000000000..d2f9a9fa3 --- /dev/null +++ b/libs/list-types/upper-tribunal-lands-chamber-daily-hearing-list/src/locales/cy.ts @@ -0,0 +1,42 @@ +export const cy = { + pageTitle: "Rhestr Gwrandawiadau Dyddiol Tribiwnlys Uwch (Siambr Tiroedd)", + downloadPdf: "Lawrlwytho fel PDF", + factLinkText: "Dod o hyd i fanylion cyswllt a gwybodaeth arall am lysoedd a thribiwnlysoedd", + factLinkUrl: "https://www.find-court-tribunal.service.gov.uk/", + factAdditionalText: "yng Nghymru a Lloegr, a rhai tribiwnlysoedd sydd heb eu datganoli yn yr Alban.", + listForDate: "Rhestr ar gyfer", + lastUpdated: "Diweddarwyd ddiwethaf", + at: "am", + openingStatement: { + contactText: + "Os yw cynrychiolydd o'r cyfryngau neu aelod o'r cyhoedd yn dymuno mynychu gwrandawiad ar Blatfform Fideo Cwmwl (CVP), dylent gysylltu ag adran restru Siambr y Tiroedd yn Lands@justice.gov.uk a fydd yn darparu rhagor o wybodaeth.", + observeLinkText: "Arsylwi gwrandawiad llys neu dribiwnlys fel newyddiadurwr, ymchwilydd neu aelod o'r cyhoedd", + observeLinkUrl: "https://www.gov.uk/guidance/observe-a-court-or-tribunal-hearing" + }, + openingStatementTitle: "Gwybodaeth bwysig", + searchCasesTitle: "Chwilio Achosion", + searchCasesLabel: "Chwilio yn ôl cyfeirnod achos, enw'r achos, barnwr, lleoliad, neu fanylion eraill", + tableHeaders: { + time: "Amser", + caseReferenceNumber: "Rhif cyfeirnod achos", + caseName: "Enw'r achos", + judges: "Barnwr/Barnwyr", + members: "Aelod/Aelodau", + hearingType: "Math o wrandawiad", + venue: "Lleoliad", + modeOfHearing: "Dull gwrandawiad", + additionalInformation: "Gwybodaeth ychwanegol" + }, + dataSource: "Ffynhonnell data", + backToTop: "Yn ôl i frig y dudalen", + cautionNote: + "Noder bod y ddogfen hon yn cynnwys Data Categori Arbennig fel y'i diffinnir yn Neddf Gwarchod Data 2018, a elwid gynt yn Ddata Personol Sensitif, a dylid ei drin yn y ffordd briodol.", + cautionReporting: + "Mae'r ddogfen hon yn cynnwys gwybodaeth a fwriedir i gynorthwyo i roi adroddiad manwl-gywir am achosion llys. Mae'n hanfodol eich bod yn sicrhau eich bod yn gwarchod y Data Categori Arbennig sydd ynddi ac yn cadw at gyfyngiadau adrodd (er enghraifft yn achos dioddefwyr a phlant). Bydd GLlTEF yn rhoi'r gorau i anfon y data os cyfyd pryder ynghylch sut y'i defnyddir.", + provenanceLabels: { + MANUAL_UPLOAD: "Llwytho â Llaw", + XHIBIT: "XHIBIT", + SNL: "SNL", + COMMON_PLATFORM: "Platfform Cyffredin" + } +}; diff --git a/libs/list-types/upper-tribunal-lands-chamber-daily-hearing-list/src/locales/en.ts b/libs/list-types/upper-tribunal-lands-chamber-daily-hearing-list/src/locales/en.ts new file mode 100644 index 000000000..d781d0d80 --- /dev/null +++ b/libs/list-types/upper-tribunal-lands-chamber-daily-hearing-list/src/locales/en.ts @@ -0,0 +1,42 @@ +export const en = { + pageTitle: "Upper Tribunal (Lands Chamber) Daily Hearing List", + downloadPdf: "Download as PDF", + factLinkText: "Find contact details and other information about courts and tribunals", + factLinkUrl: "https://www.find-court-tribunal.service.gov.uk/", + factAdditionalText: "in England and Wales, and some non-devolved tribunals in Scotland.", + listForDate: "List for", + lastUpdated: "Last updated", + at: "at", + openingStatement: { + contactText: + "If a representative of the media or a member of the public wishes to attend a Cloud Video Platform (CVP) hearing they should contact the Lands Chamber listing section Lands@justice.gov.uk who will provide further information.", + observeLinkText: "Observe a court or tribunal hearing as a journalist, researcher or member of the public", + observeLinkUrl: "https://www.gov.uk/guidance/observe-a-court-or-tribunal-hearing" + }, + openingStatementTitle: "Important information", + searchCasesTitle: "Search Cases", + searchCasesLabel: "Search by case reference, case name, judge, venue, or other details", + tableHeaders: { + time: "Time", + caseReferenceNumber: "Case reference number", + caseName: "Case name", + judges: "Judge(s)", + members: "Member(s)", + hearingType: "Hearing type", + venue: "Venue", + modeOfHearing: "Mode of hearing", + additionalInformation: "Additional information" + }, + dataSource: "Data source", + backToTop: "Back to top", + cautionNote: + "Note this document contains Special Category Data as defined by Data Protection Act 2018, formally known as Sensitive Personal Data, and should be handled appropriately.", + cautionReporting: + "This document contains information intended to assist the accurate reporting of court proceedings. It is vital you ensure that you safeguard the Special Category Data included and abide by reporting restrictions (for example on victims and children). HMCTS will stop sending the data if there is concern about how it will be used.", + provenanceLabels: { + MANUAL_UPLOAD: "Manual Upload", + XHIBIT: "XHIBIT", + SNL: "SNL", + COMMON_PLATFORM: "Common Platform" + } +}; diff --git a/libs/list-types/upper-tribunal-lands-chamber-daily-hearing-list/src/models/types.ts b/libs/list-types/upper-tribunal-lands-chamber-daily-hearing-list/src/models/types.ts new file mode 100644 index 000000000..5f6837e16 --- /dev/null +++ b/libs/list-types/upper-tribunal-lands-chamber-daily-hearing-list/src/models/types.ts @@ -0,0 +1,13 @@ +export interface UtlcHearing { + time: string; + caseReferenceNumber: string; + caseName: string; + judges: string; + members: string; + hearingType: string; + venue: string; + modeOfHearing: string; + additionalInformation: string; +} + +export type UtlcHearingList = UtlcHearing[]; diff --git a/libs/list-types/upper-tribunal-lands-chamber-daily-hearing-list/src/pdf/pdf-generator.test.ts b/libs/list-types/upper-tribunal-lands-chamber-daily-hearing-list/src/pdf/pdf-generator.test.ts new file mode 100644 index 000000000..3cdb268fb --- /dev/null +++ b/libs/list-types/upper-tribunal-lands-chamber-daily-hearing-list/src/pdf/pdf-generator.test.ts @@ -0,0 +1,163 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { mockUploadBlob } = vi.hoisted(() => ({ + mockUploadBlob: vi.fn() +})); + +vi.mock("@hmcts/azure-blob", () => ({ + uploadBlob: mockUploadBlob, + CONTAINER: { ARTEFACT: "artefact", PUBLICATIONS: "publications" } +})); + +vi.mock("@hmcts/pdf-generation", () => ({ + generatePdfFromHtml: vi.fn() +})); + +vi.mock("../rendering/renderer.js", () => ({ + renderUtlcDailyHearingListData: vi.fn() +})); + +import { generatePdfFromHtml } from "@hmcts/pdf-generation"; +import { renderUtlcDailyHearingListData } from "../rendering/renderer.js"; +import { generateUtlcDailyHearingListPdf } from "./pdf-generator.js"; + +const mockRenderedData = { + header: { + listTitle: "Upper Tribunal (Lands Chamber) Daily Hearing List", + hearingDate: "15 January 2025", + lastUpdatedDate: "15 January 2025", + lastUpdatedTime: "9am" + }, + hearings: [] +}; + +const mockHearingList = [ + { + time: "10:00am", + caseReferenceNumber: "LC/2025/0001", + caseName: "Smith v Jones", + judges: "Judge Smith", + members: "Member Jones", + hearingType: "Substantive hearing", + venue: "Royal Courts of Justice", + modeOfHearing: "CVP", + additionalInformation: "" + } +]; + +describe("generateUtlcDailyHearingListPdf", () => { + beforeEach(() => { + vi.clearAllMocks(); + + vi.mocked(renderUtlcDailyHearingListData).mockReturnValue(mockRenderedData); + mockUploadBlob.mockResolvedValue(undefined); + }); + + it("should generate PDF successfully", async () => { + const pdfBuffer = Buffer.from("PDF content"); + vi.mocked(generatePdfFromHtml).mockResolvedValue({ + success: true, + pdfBuffer, + sizeBytes: 1024 + }); + + const result = await generateUtlcDailyHearingListPdf({ + artefactId: "test-artefact-123", + contentDate: new Date("2025-01-15"), + locale: "en", + locationId: "240", + jsonData: mockHearingList + }); + + expect(result.success).toBe(true); + expect(result.pdfPath).toContain("test-artefact-123.pdf"); + expect(result.sizeBytes).toBe(1024); + expect(result.exceedsMaxSize).toBe(false); + }); + + it("should return exceedsMaxSize true when PDF is over 2MB", async () => { + const largePdfBuffer = Buffer.alloc(3 * 1024 * 1024); + vi.mocked(generatePdfFromHtml).mockResolvedValue({ + success: true, + pdfBuffer: largePdfBuffer, + sizeBytes: 3 * 1024 * 1024 + }); + + const result = await generateUtlcDailyHearingListPdf({ + artefactId: "large-pdf-123", + contentDate: new Date("2025-01-15"), + locale: "en", + locationId: "240", + jsonData: mockHearingList + }); + + expect(result.success).toBe(true); + expect(result.exceedsMaxSize).toBe(true); + }); + + it("should return error when PDF generation fails", async () => { + vi.mocked(generatePdfFromHtml).mockResolvedValue({ + success: false, + error: "Puppeteer crashed" + }); + + const result = await generateUtlcDailyHearingListPdf({ + artefactId: "failed-pdf", + contentDate: new Date("2025-01-15"), + locale: "en", + locationId: "240", + jsonData: mockHearingList + }); + + expect(result.success).toBe(false); + expect(result.error).toBe("Puppeteer crashed"); + }); + + it("should return error when PDF generation reports success but returns no buffer", async () => { + // Arrange + vi.mocked(generatePdfFromHtml).mockResolvedValue({ + success: true, + pdfBuffer: undefined, + sizeBytes: undefined + }); + + // Act + const result = await generateUtlcDailyHearingListPdf({ + artefactId: "no-buffer-pdf", + contentDate: new Date("2025-01-15"), + locale: "en", + locationId: "240", + jsonData: mockHearingList + }); + + // Assert + expect(result.success).toBe(false); + expect(result.error).toBe("PDF generation failed"); + }); + + it("should pass correct render options to renderer", async () => { + vi.mocked(generatePdfFromHtml).mockResolvedValue({ + success: true, + pdfBuffer: Buffer.from("PDF"), + sizeBytes: 100 + }); + + const contentDate = new Date("2025-01-15"); + + await generateUtlcDailyHearingListPdf({ + artefactId: "test-render-options", + contentDate, + locale: "cy", + locationId: "999", + jsonData: mockHearingList + }); + + expect(renderUtlcDailyHearingListData).toHaveBeenCalledWith(mockHearingList, { + locale: "cy", + courtName: "Upper Tribunal (Lands Chamber)", + contentDate, + lastReceivedDate: expect.any(String), + listTitle: "Upper Tribunal (Lands Chamber) Daily Hearing List" + }); + }); +}); diff --git a/libs/list-types/upper-tribunal-lands-chamber-daily-hearing-list/src/pdf/pdf-generator.ts b/libs/list-types/upper-tribunal-lands-chamber-daily-hearing-list/src/pdf/pdf-generator.ts new file mode 100644 index 000000000..36c48de69 --- /dev/null +++ b/libs/list-types/upper-tribunal-lands-chamber-daily-hearing-list/src/pdf/pdf-generator.ts @@ -0,0 +1,20 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { generatePdfFromHtml } from "@hmcts/pdf-generation"; +import { PROVENANCE_LABELS } from "@hmcts/publication"; +import { createUtDailyHearingListPdfGenerator } from "@hmcts/upper-tribunal-common"; +import type { UtlcHearingList } from "../models/types.js"; +import { renderUtlcDailyHearingListData } from "../rendering/renderer.js"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +export const generateUtlcDailyHearingListPdf = createUtDailyHearingListPdfGenerator( + "Upper Tribunal (Lands Chamber)", + "Upper Tribunal (Lands Chamber) Daily Hearing List", + renderUtlcDailyHearingListData, + () => import("../locales/en.js"), + () => import("../locales/cy.js"), + __dirname, + PROVENANCE_LABELS, + generatePdfFromHtml +); diff --git a/libs/list-types/upper-tribunal-lands-chamber-daily-hearing-list/src/pdf/pdf-template.njk b/libs/list-types/upper-tribunal-lands-chamber-daily-hearing-list/src/pdf/pdf-template.njk new file mode 100644 index 000000000..de0476ab0 --- /dev/null +++ b/libs/list-types/upper-tribunal-lands-chamber-daily-hearing-list/src/pdf/pdf-template.njk @@ -0,0 +1,69 @@ + + + + + + {{ header.listTitle }} + + + +
+

{{ header.listTitle }}

+ +

{{ t.factLinkText }} {{ t.factAdditionalText }}

+ +

{{ t.listForDate }} {{ header.hearingDate }}

+

{{ t.lastUpdated }} {{ header.lastUpdatedDate }} {{ t.at }} {{ header.lastUpdatedTime }}

+
+ +
+

{{ t.openingStatement.contactText }}

+

{{ t.openingStatement.observeLinkText }}

+
+ + {% if hearings.length > 0 %} + + + + + + + + + + + + + + + + {% for hearing in hearings %} + + + + + + + + + + + + {% endfor %} + +
{{ t.tableHeaders.time }}{{ t.tableHeaders.caseReferenceNumber }}{{ t.tableHeaders.caseName }}{{ t.tableHeaders.judges }}{{ t.tableHeaders.members }}{{ t.tableHeaders.hearingType }}{{ t.tableHeaders.venue }}{{ t.tableHeaders.modeOfHearing }}{{ t.tableHeaders.additionalInformation }}
{{ hearing.time }}{{ hearing.caseReferenceNumber }}{{ hearing.caseName }}{{ hearing.judges }}{{ hearing.members }}{{ hearing.hearingType }}{{ hearing.venue }}{{ hearing.modeOfHearing }}{{ hearing.additionalInformation }}
+ {% else %} +

No hearings scheduled.

+ {% endif %} + + + + diff --git a/libs/list-types/upper-tribunal-lands-chamber-daily-hearing-list/src/rendering/renderer.test.ts b/libs/list-types/upper-tribunal-lands-chamber-daily-hearing-list/src/rendering/renderer.test.ts new file mode 100644 index 000000000..9906de221 --- /dev/null +++ b/libs/list-types/upper-tribunal-lands-chamber-daily-hearing-list/src/rendering/renderer.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, it } from "vitest"; +import type { UtlcHearingList } from "../models/types.js"; +import { renderUtlcDailyHearingListData } from "./renderer.js"; + +describe("renderUtlcDailyHearingListData", () => { + const baseOptions = { + locale: "en", + courtName: "Upper Tribunal (Lands Chamber)", + contentDate: new Date(2025, 0, 15), + lastReceivedDate: "2025-01-15T09:55:00Z", + listTitle: "Upper Tribunal (Lands Chamber) Daily Hearing List" + }; + + it("should render hearing list with header", () => { + const hearingList: UtlcHearingList = [ + { + time: "10:00am", + caseReferenceNumber: "LC/2025/0001", + caseName: "Smith v Jones", + judges: "Judge Smith", + members: "Member Jones", + hearingType: "Substantive hearing", + venue: "Royal Courts of Justice", + modeOfHearing: "CVP", + additionalInformation: "Remote hearing" + } + ]; + + const result = renderUtlcDailyHearingListData(hearingList, baseOptions); + + expect(result.header.listTitle).toBe("Upper Tribunal (Lands Chamber) Daily Hearing List"); + expect(result.header.hearingDate).toBe("15 January 2025"); + expect(result.header.lastUpdatedDate).toBe("15 January 2025"); + expect(result.header.lastUpdatedTime).toContain("am"); + }); + + it("should render multiple hearings", () => { + const hearingList: UtlcHearingList = [ + { + time: "10:00am", + caseReferenceNumber: "LC/2025/0001", + caseName: "Smith v Jones", + judges: "Judge Smith", + members: "Member Jones", + hearingType: "Substantive hearing", + venue: "Royal Courts of Justice", + modeOfHearing: "In person", + additionalInformation: "" + }, + { + time: "2:00pm", + caseReferenceNumber: "LC/2025/0002", + caseName: "Brown v Green", + judges: "Judge Brown", + members: "Member Davis", + hearingType: "Preliminary hearing", + venue: "Royal Courts of Justice", + modeOfHearing: "CVP", + additionalInformation: "" + } + ]; + + const result = renderUtlcDailyHearingListData(hearingList, baseOptions); + + expect(result.hearings).toHaveLength(2); + expect(result.hearings[0].caseReferenceNumber).toBe("LC/2025/0001"); + expect(result.hearings[1].caseReferenceNumber).toBe("LC/2025/0002"); + }); + + it("should include modeOfHearing in rendered data", () => { + const hearingList: UtlcHearingList = [ + { + time: "10:00am", + caseReferenceNumber: "LC/2025/0001", + caseName: "Smith v Jones", + judges: "Judge Smith", + members: "Member Jones", + hearingType: "Substantive hearing", + venue: "Royal Courts of Justice", + modeOfHearing: "CVP", + additionalInformation: "" + } + ]; + + const result = renderUtlcDailyHearingListData(hearingList, baseOptions); + + expect(result.hearings[0].modeOfHearing).toBe("CVP"); + }); + + it("should include additionalInformation in rendered data", () => { + const hearingList: UtlcHearingList = [ + { + time: "10:00am", + caseReferenceNumber: "LC/2025/0001", + caseName: "Smith v Jones", + judges: "Judge Smith", + members: "Member Jones", + hearingType: "Substantive hearing", + venue: "Royal Courts of Justice", + modeOfHearing: "In person", + additionalInformation: "Remote hearing" + } + ]; + + const result = renderUtlcDailyHearingListData(hearingList, baseOptions); + + expect(result.hearings[0].additionalInformation).toBe("Remote hearing"); + }); + + it("should handle empty hearing list", () => { + const hearingList: UtlcHearingList = []; + + const result = renderUtlcDailyHearingListData(hearingList, baseOptions); + + expect(result.hearings).toHaveLength(0); + expect(result.header.listTitle).toBe("Upper Tribunal (Lands Chamber) Daily Hearing List"); + }); + + it("should preserve all hearing fields without modification", () => { + const hearingList: UtlcHearingList = [ + { + time: "10:30am", + caseReferenceNumber: "LC/2025/0001/A", + caseName: "Smith & Jones v Landowner", + judges: "Judge Smith; Judge Brown", + members: "Member Jones, Member Davis", + hearingType: "Substantive hearing - final", + venue: "Royal Courts of Justice, Room 3", + modeOfHearing: "Cloud Video Platform (CVP)", + additionalInformation: "Contact Lands@justice.gov.uk for details" + } + ]; + + const result = renderUtlcDailyHearingListData(hearingList, baseOptions); + + expect(result.hearings[0].time).toBe("10:30am"); + expect(result.hearings[0].caseReferenceNumber).toBe("LC/2025/0001/A"); + expect(result.hearings[0].caseName).toBe("Smith & Jones v Landowner"); + expect(result.hearings[0].judges).toBe("Judge Smith; Judge Brown"); + expect(result.hearings[0].members).toBe("Member Jones, Member Davis"); + expect(result.hearings[0].hearingType).toBe("Substantive hearing - final"); + expect(result.hearings[0].venue).toBe("Royal Courts of Justice, Room 3"); + expect(result.hearings[0].modeOfHearing).toBe("Cloud Video Platform (CVP)"); + expect(result.hearings[0].additionalInformation).toBe("Contact Lands@justice.gov.uk for details"); + }); +}); diff --git a/libs/list-types/upper-tribunal-lands-chamber-daily-hearing-list/src/rendering/renderer.ts b/libs/list-types/upper-tribunal-lands-chamber-daily-hearing-list/src/rendering/renderer.ts new file mode 100644 index 000000000..c5af5c485 --- /dev/null +++ b/libs/list-types/upper-tribunal-lands-chamber-daily-hearing-list/src/rendering/renderer.ts @@ -0,0 +1,47 @@ +import { formatDisplayDate, formatLastUpdatedDateTime } from "@hmcts/list-types-common"; +import type { UtlcHearing, UtlcHearingList } from "../models/types.js"; + +export interface RenderOptions { + locale: string; + courtName: string; + contentDate: Date; + lastReceivedDate: string; + listTitle: string; +} + +export interface RenderedData { + header: { + listTitle: string; + hearingDate: string; + lastUpdatedDate: string; + lastUpdatedTime: string; + }; + hearings: UtlcHearing[]; +} + +export function renderUtlcDailyHearingListData(hearingList: UtlcHearingList, options: RenderOptions): RenderedData { + const hearingDate = formatDisplayDate(options.contentDate, options.locale); + const { date: lastUpdatedDate, time: lastUpdatedTime } = formatLastUpdatedDateTime(options.lastReceivedDate, options.locale); + + const renderedHearings = hearingList.map((hearing) => ({ + time: hearing.time, + caseReferenceNumber: hearing.caseReferenceNumber, + caseName: hearing.caseName, + judges: hearing.judges, + members: hearing.members, + hearingType: hearing.hearingType, + venue: hearing.venue, + modeOfHearing: hearing.modeOfHearing, + additionalInformation: hearing.additionalInformation + })); + + return { + header: { + listTitle: options.listTitle, + hearingDate, + lastUpdatedDate, + lastUpdatedTime + }, + hearings: renderedHearings + }; +} diff --git a/libs/list-types/upper-tribunal-lands-chamber-daily-hearing-list/src/schemas/upper-tribunal-lands-chamber-daily-hearing-list.json b/libs/list-types/upper-tribunal-lands-chamber-daily-hearing-list/src/schemas/upper-tribunal-lands-chamber-daily-hearing-list.json new file mode 100644 index 000000000..0cfd769af --- /dev/null +++ b/libs/list-types/upper-tribunal-lands-chamber-daily-hearing-list/src/schemas/upper-tribunal-lands-chamber-daily-hearing-list.json @@ -0,0 +1,75 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Root", + "type": "array", + "items": { + "title": "Hearing list", + "type": "object", + "required": ["time", "caseReferenceNumber", "caseName", "judges", "members", "hearingType", "venue", "modeOfHearing", "additionalInformation"], + "properties": { + "time": { + "title": "Time", + "type": "string", + "default": "", + "examples": ["10am"], + "pattern": "^\\d{1,2}([:.]\\d{2})?[ap]m\\s*$" + }, + "caseReferenceNumber": { + "title": "Case reference number", + "type": "string", + "default": "", + "examples": ["12345"], + "pattern": "^(?!(.|\\r|\\n)*<[^>]+>)(.|\\r|\\n)*$" + }, + "caseName": { + "title": "Case name", + "type": "string", + "default": "", + "examples": ["Case name 1"], + "pattern": "^(?!(.|\\r|\\n)*<[^>]+>)(.|\\r|\\n)*$" + }, + "judges": { + "title": "Judges", + "type": "string", + "default": "", + "examples": ["Judge 1"], + "pattern": "^(?!(.|\\r|\\n)*<[^>]+>)(.|\\r|\\n)*$" + }, + "members": { + "title": "Members", + "type": "string", + "default": "", + "examples": ["Member 1"], + "pattern": "^(?!(.|\\r|\\n)*<[^>]+>)(.|\\r|\\n)*$" + }, + "hearingType": { + "title": "Hearing type", + "type": "string", + "default": "", + "examples": ["Hearing type 1"], + "pattern": "^(?!(.|\\r|\\n)*<[^>]+>)(.|\\r|\\n)*$" + }, + "venue": { + "title": "Venue", + "type": "string", + "default": "", + "examples": ["Venue 1"], + "pattern": "^(?!(.|\\r|\\n)*<[^>]+>)(.|\\r|\\n)*$" + }, + "modeOfHearing": { + "title": "Mode of hearing", + "type": "string", + "default": "", + "examples": ["Mode of hearing 1"], + "pattern": "^(?!(.|\\r|\\n)*<[^>]+>)(.|\\r|\\n)*$" + }, + "additionalInformation": { + "title": "Additional information", + "type": "string", + "default": "", + "examples": ["This is additional information"], + "pattern": "^(?!(.|\\r|\\n)*<[^>]+>)(.|\\r|\\n)*$" + } + } + } +} diff --git a/libs/list-types/upper-tribunal-lands-chamber-daily-hearing-list/src/validation/json-validator.test.ts b/libs/list-types/upper-tribunal-lands-chamber-daily-hearing-list/src/validation/json-validator.test.ts new file mode 100644 index 000000000..657d668be --- /dev/null +++ b/libs/list-types/upper-tribunal-lands-chamber-daily-hearing-list/src/validation/json-validator.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it, vi } from "vitest"; + +vi.mock("@hmcts/publication", () => ({ + validateJson: vi.fn() +})); + +import { validateJson } from "@hmcts/publication"; +import { validateUtLandsChamberDailyHearingList } from "./json-validator.js"; + +describe("validateUtLandsChamberDailyHearingList", () => { + it("should call validateJson with the provided data and schema and return the result", () => { + // Arrange + const mockData = { hearings: [] }; + const mockResult = { isValid: true, errors: [] }; + vi.mocked(validateJson).mockReturnValue(mockResult as any); + + // Act + const result = validateUtLandsChamberDailyHearingList(mockData); + + // Assert + expect(validateJson).toHaveBeenCalledWith(mockData, expect.any(Object), "1.0"); + expect(result).toBe(mockResult); + }); + + it("should return the validation result when data is invalid", () => { + // Arrange + const mockData = { invalid: true }; + const mockResult = { isValid: false, errors: ["Missing required field"] }; + vi.mocked(validateJson).mockReturnValue(mockResult as any); + + // Act + const result = validateUtLandsChamberDailyHearingList(mockData); + + // Assert + expect(validateJson).toHaveBeenCalledWith(mockData, expect.any(Object), "1.0"); + expect(result).toBe(mockResult); + }); +}); diff --git a/libs/list-types/upper-tribunal-lands-chamber-daily-hearing-list/src/validation/json-validator.ts b/libs/list-types/upper-tribunal-lands-chamber-daily-hearing-list/src/validation/json-validator.ts new file mode 100644 index 000000000..030d5f4d9 --- /dev/null +++ b/libs/list-types/upper-tribunal-lands-chamber-daily-hearing-list/src/validation/json-validator.ts @@ -0,0 +1,6 @@ +import { type ValidationResult, validateJson } from "@hmcts/publication"; +import schema from "../schemas/upper-tribunal-lands-chamber-daily-hearing-list.json" with { type: "json" }; + +export function validateUtLandsChamberDailyHearingList(jsonData: unknown): ValidationResult { + return validateJson(jsonData, schema, "1.0"); +} diff --git a/libs/list-types/upper-tribunal-lands-chamber-daily-hearing-list/tsconfig.json b/libs/list-types/upper-tribunal-lands-chamber-daily-hearing-list/tsconfig.json new file mode 100644 index 000000000..a029ee937 --- /dev/null +++ b/libs/list-types/upper-tribunal-lands-chamber-daily-hearing-list/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "declaration": true, + "declarationMap": true, + "resolveJsonModule": true + }, + "include": ["src/**/*"], + "exclude": ["**/*.test.ts", "**/*.spec.ts", "dist", "node_modules", "src/assets/"] +} diff --git a/libs/list-types/upper-tribunal-tax-and-chancery-chamber-daily-hearing-list/package.json b/libs/list-types/upper-tribunal-tax-and-chancery-chamber-daily-hearing-list/package.json new file mode 100644 index 000000000..cb398d269 --- /dev/null +++ b/libs/list-types/upper-tribunal-tax-and-chancery-chamber-daily-hearing-list/package.json @@ -0,0 +1,42 @@ +{ + "name": "@hmcts/upper-tribunal-tax-and-chancery-chamber-daily-hearing-list", + "version": "1.0.0", + "type": "module", + "exports": { + ".": { + "production": "./dist/index.js", + "default": "./src/index.ts" + }, + "./config": { + "production": "./dist/config.js", + "default": "./src/config.ts" + } + }, + "scripts": { + "build": "tsc && yarn build:nunjucks && yarn build:schemas", + "build:nunjucks": "mkdir -p dist/pdf && cd src/pdf && find . -name '*.njk' -exec sh -c 'mkdir -p ../../dist/pdf/$(dirname {}) && cp {} ../../dist/pdf/{}' \\;", + "build:schemas": "mkdir -p dist/schemas && cp src/schemas/*.json dist/schemas/", + "dev": "tsc --watch", + "test": "vitest run", + "test:watch": "vitest watch", + "format": "biome format --write .", + "lint": "biome check .", + "lint:fix": "biome check --write --unsafe ." + }, + "dependencies": { + "@hmcts/list-types-common": "workspace:*", + "@hmcts/pdf-generation": "workspace:*", + "@hmcts/postgres-prisma": "workspace:*", + "@hmcts/upper-tribunal-common": "workspace:*", + "luxon": "3.7.2" + }, + "devDependencies": { + "@types/luxon": "3.7.1", + "@types/node": "24.10.4", + "typescript": "6.0.3", + "vitest": "4.1.8" + }, + "peerDependencies": { + "express": "^5.1.0" + } +} diff --git a/libs/list-types/upper-tribunal-tax-and-chancery-chamber-daily-hearing-list/src/config.test.ts b/libs/list-types/upper-tribunal-tax-and-chancery-chamber-daily-hearing-list/src/config.test.ts new file mode 100644 index 000000000..14c041cad --- /dev/null +++ b/libs/list-types/upper-tribunal-tax-and-chancery-chamber-daily-hearing-list/src/config.test.ts @@ -0,0 +1,67 @@ +import { existsSync } from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { assets, moduleRoot, schemaPath } from "./config.js"; + +describe("upper-tribunal-tax-and-chancery-chamber-daily-hearing-list config", () => { + describe("moduleRoot", () => { + it("should be defined", () => { + expect(moduleRoot).toBeDefined(); + expect(typeof moduleRoot).toBe("string"); + }); + + it("should point to an existing directory", () => { + expect(existsSync(moduleRoot)).toBe(true); + }); + + it("should be an absolute path", () => { + expect(path.isAbsolute(moduleRoot)).toBe(true); + }); + }); + + describe("schemaPath", () => { + it("should be defined", () => { + expect(schemaPath).toBeDefined(); + expect(typeof schemaPath).toBe("string"); + }); + + it("should point to the schema file", () => { + expect(schemaPath).toContain("upper-tribunal-tax-and-chancery-chamber-daily-hearing-list.json"); + }); + + it("should be an absolute path", () => { + expect(path.isAbsolute(schemaPath)).toBe(true); + }); + + it("should point to existing schema file", () => { + expect(existsSync(schemaPath)).toBe(true); + }); + }); + + describe("assets", () => { + it("should be defined", () => { + expect(assets).toBeDefined(); + expect(typeof assets).toBe("string"); + }); + + it("should point to assets directory", () => { + expect(assets).toContain("assets"); + }); + + it("should be an absolute path", () => { + expect(path.isAbsolute(assets)).toBe(true); + }); + + it("should have valid path structure", () => { + expect(assets).toBeTruthy(); + }); + + it("should end with trailing slash", () => { + expect(assets).toMatch(/\/$/); + }); + + it("should be subdirectory of moduleRoot", () => { + expect(assets.startsWith(moduleRoot)).toBe(true); + }); + }); +}); diff --git a/libs/list-types/upper-tribunal-tax-and-chancery-chamber-daily-hearing-list/src/config.ts b/libs/list-types/upper-tribunal-tax-and-chancery-chamber-daily-hearing-list/src/config.ts new file mode 100644 index 000000000..7a01d6469 --- /dev/null +++ b/libs/list-types/upper-tribunal-tax-and-chancery-chamber-daily-hearing-list/src/config.ts @@ -0,0 +1,9 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +export const moduleRoot = __dirname; +export const assets = path.join(__dirname, "assets/"); +export const schemaPath = path.join(__dirname, "schemas/upper-tribunal-tax-and-chancery-chamber-daily-hearing-list.json"); diff --git a/libs/list-types/upper-tribunal-tax-and-chancery-chamber-daily-hearing-list/src/conversion/utcc-config.test.ts b/libs/list-types/upper-tribunal-tax-and-chancery-chamber-daily-hearing-list/src/conversion/utcc-config.test.ts new file mode 100644 index 000000000..f4c79394c --- /dev/null +++ b/libs/list-types/upper-tribunal-tax-and-chancery-chamber-daily-hearing-list/src/conversion/utcc-config.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; +import { UTCC_EXCEL_CONFIG } from "./utcc-config.js"; + +describe("UTCC_EXCEL_CONFIG", () => { + it("should have correct configuration structure", () => { + expect(UTCC_EXCEL_CONFIG).toBeDefined(); + expect(UTCC_EXCEL_CONFIG.fields).toHaveLength(8); + expect(UTCC_EXCEL_CONFIG.minRows).toBe(1); + }); + + it("should have all expected field names", () => { + const fieldNames = UTCC_EXCEL_CONFIG.fields.map((f) => f.fieldName); + expect(fieldNames).toEqual(["time", "caseReferenceNumber", "caseName", "judges", "members", "hearingType", "venue", "additionalInformation"]); + }); + + it("should have correct required flags", () => { + const requiredFields = UTCC_EXCEL_CONFIG.fields.filter((f) => f.required).map((f) => f.fieldName); + const optionalFields = UTCC_EXCEL_CONFIG.fields.filter((f) => !f.required).map((f) => f.fieldName); + + expect(requiredFields).toEqual(["time", "caseReferenceNumber", "caseName", "judges", "members", "hearingType", "venue", "additionalInformation"]); + expect(optionalFields).toEqual([]); + }); + + describe("field validators", () => { + it("should accept values without HTML tags", () => { + for (const field of UTCC_EXCEL_CONFIG.fields) { + expect(() => field.validators?.[0]("Plain text value", 1)).not.toThrow(); + } + }); + + it("should reject values containing HTML tags", () => { + for (const field of UTCC_EXCEL_CONFIG.fields) { + expect(() => field.validators?.[0]("bold", 1)).toThrow(); + } + }); + }); +}); diff --git a/libs/list-types/upper-tribunal-tax-and-chancery-chamber-daily-hearing-list/src/conversion/utcc-config.ts b/libs/list-types/upper-tribunal-tax-and-chancery-chamber-daily-hearing-list/src/conversion/utcc-config.ts new file mode 100644 index 000000000..6ffee0b88 --- /dev/null +++ b/libs/list-types/upper-tribunal-tax-and-chancery-chamber-daily-hearing-list/src/conversion/utcc-config.ts @@ -0,0 +1,58 @@ +import { createConverter, type ExcelConverterConfig, registerConverterByName, validateNoHtmlTags } from "@hmcts/list-types-common"; + +export const UTCC_EXCEL_CONFIG: ExcelConverterConfig = { + fields: [ + { + header: "Time", + fieldName: "time", + required: true, + validators: [(value, rowNumber) => validateNoHtmlTags(value, "Time", rowNumber)] + }, + { + header: "Case Reference Number", + fieldName: "caseReferenceNumber", + required: true, + validators: [(value, rowNumber) => validateNoHtmlTags(value, "Case Reference Number", rowNumber)] + }, + { + header: "Case Name", + fieldName: "caseName", + required: true, + validators: [(value, rowNumber) => validateNoHtmlTags(value, "Case Name", rowNumber)] + }, + { + header: "Judge(s)", + fieldName: "judges", + required: true, + validators: [(value, rowNumber) => validateNoHtmlTags(value, "Judge(s)", rowNumber)] + }, + { + header: "Member(s)", + fieldName: "members", + required: true, + validators: [(value, rowNumber) => validateNoHtmlTags(value, "Member(s)", rowNumber)] + }, + { + header: "Hearing Type", + fieldName: "hearingType", + required: true, + validators: [(value, rowNumber) => validateNoHtmlTags(value, "Hearing Type", rowNumber)] + }, + { + header: "Venue", + fieldName: "venue", + required: true, + validators: [(value, rowNumber) => validateNoHtmlTags(value, "Venue", rowNumber)] + }, + { + header: "Additional Information", + fieldName: "additionalInformation", + required: true, + validators: [(value, rowNumber) => validateNoHtmlTags(value, "Additional Information", rowNumber)] + } + ], + minRows: 1 +}; + +const utccConverter = createConverter(UTCC_EXCEL_CONFIG); +registerConverterByName("UT_TAX_AND_CHANCERY_CHAMBER_DAILY_HEARING_LIST", utccConverter); diff --git a/libs/list-types/upper-tribunal-tax-and-chancery-chamber-daily-hearing-list/src/email-summary/summary-builder.test.ts b/libs/list-types/upper-tribunal-tax-and-chancery-chamber-daily-hearing-list/src/email-summary/summary-builder.test.ts new file mode 100644 index 000000000..62db4fe8e --- /dev/null +++ b/libs/list-types/upper-tribunal-tax-and-chancery-chamber-daily-hearing-list/src/email-summary/summary-builder.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from "vitest"; +import type { UtccHearingList } from "../models/types.js"; +import { extractCaseSummary, formatCaseSummaryForEmail, SPECIAL_CATEGORY_DATA_WARNING } from "./summary-builder.js"; + +describe("SPECIAL_CATEGORY_DATA_WARNING", () => { + it("should contain required warning text", () => { + expect(SPECIAL_CATEGORY_DATA_WARNING).toContain("Special Category Data"); + expect(SPECIAL_CATEGORY_DATA_WARNING).toContain("Data Protection Act 2018"); + }); +}); + +describe("extractCaseSummary", () => { + it("should extract case summaries from hearing list", () => { + const hearingList: UtccHearingList = [ + { + time: "10:00am", + caseReferenceNumber: "UTTC/2025/0001", + caseName: "Smith v HMRC", + judges: "Judge Smith", + members: "Member Jones", + hearingType: "Substantive hearing", + venue: "Field House" + }, + { + time: "2:00pm", + caseReferenceNumber: "UTTC/2025/0002", + caseName: "Brown v HMRC", + judges: "Judge Brown", + members: "Member Davis", + hearingType: "Preliminary hearing", + venue: "Field House" + } + ]; + + const result = extractCaseSummary(hearingList); + + expect(result).toHaveLength(2); + expect(result[0]).toEqual([ + { label: "Time", value: "10:00am" }, + { label: "Case reference number", value: "UTTC/2025/0001" }, + { label: "Case name", value: "Smith v HMRC" } + ]); + expect(result[1]).toEqual([ + { label: "Time", value: "2:00pm" }, + { label: "Case reference number", value: "UTTC/2025/0002" }, + { label: "Case name", value: "Brown v HMRC" } + ]); + }); + + it("should handle empty hearing list", () => { + const hearingList: UtccHearingList = []; + + const result = extractCaseSummary(hearingList); + + expect(result).toHaveLength(0); + }); + + it("should handle missing values with empty string", () => { + const hearingList: UtccHearingList = [ + { + time: "", + caseReferenceNumber: "", + caseName: "", + judges: "", + members: "", + hearingType: "", + venue: "" + } + ]; + + const result = extractCaseSummary(hearingList); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual([ + { label: "Time", value: "" }, + { label: "Case reference number", value: "" }, + { label: "Case name", value: "" } + ]); + }); +}); + +describe("formatCaseSummaryForEmail", () => { + it("should format single case summary correctly", () => { + const items = [ + [ + { label: "Time", value: "10:00am" }, + { label: "Case reference number", value: "UTTC/2025/0001" }, + { label: "Case name", value: "Smith v HMRC" } + ] + ]; + + const result = formatCaseSummaryForEmail(items); + + expect(result).toContain("---"); + expect(result).toContain("Time - 10:00am"); + expect(result).toContain("Case reference number - UTTC/2025/0001"); + expect(result).toContain("Case name - Smith v HMRC"); + }); + + it("should handle empty case list", () => { + const result = formatCaseSummaryForEmail([]); + + expect(result).toBe("No cases scheduled."); + }); +}); diff --git a/libs/list-types/upper-tribunal-tax-and-chancery-chamber-daily-hearing-list/src/email-summary/summary-builder.ts b/libs/list-types/upper-tribunal-tax-and-chancery-chamber-daily-hearing-list/src/email-summary/summary-builder.ts new file mode 100644 index 000000000..8d69085b2 --- /dev/null +++ b/libs/list-types/upper-tribunal-tax-and-chancery-chamber-daily-hearing-list/src/email-summary/summary-builder.ts @@ -0,0 +1,12 @@ +import { type CaseSummary, formatCaseSummaryForEmail, SPECIAL_CATEGORY_DATA_WARNING } from "@hmcts/list-types-common"; +import type { UtccHearingList } from "../models/types.js"; + +export { formatCaseSummaryForEmail, SPECIAL_CATEGORY_DATA_WARNING }; + +export function extractCaseSummary(jsonData: UtccHearingList): CaseSummary[] { + return jsonData.map((hearing) => [ + { label: "Time", value: hearing.time || "" }, + { label: "Case reference number", value: hearing.caseReferenceNumber || "" }, + { label: "Case name", value: hearing.caseName || "" } + ]); +} diff --git a/libs/list-types/upper-tribunal-tax-and-chancery-chamber-daily-hearing-list/src/index.ts b/libs/list-types/upper-tribunal-tax-and-chancery-chamber-daily-hearing-list/src/index.ts new file mode 100644 index 000000000..a605fddc2 --- /dev/null +++ b/libs/list-types/upper-tribunal-tax-and-chancery-chamber-daily-hearing-list/src/index.ts @@ -0,0 +1,12 @@ +import "./conversion/utcc-config.js"; // Register converter on module load + +// Business logic exports +export type { ValidationResult } from "@hmcts/publication"; +export * from "./email-summary/summary-builder.js"; +export { cy as upperTribunalTaxAndChanceryChamberDailyHearingListCy } from "./locales/cy.js"; +// Locale exports +export { en as upperTribunalTaxAndChanceryChamberDailyHearingListEn } from "./locales/en.js"; +export * from "./models/types.js"; +export * from "./pdf/pdf-generator.js"; +export * from "./rendering/renderer.js"; +export { validateUtTaxAndChanceryChamberDailyHearingList } from "./validation/json-validator.js"; diff --git a/libs/list-types/upper-tribunal-tax-and-chancery-chamber-daily-hearing-list/src/locales/cy.ts b/libs/list-types/upper-tribunal-tax-and-chancery-chamber-daily-hearing-list/src/locales/cy.ts new file mode 100644 index 000000000..7cee90ba7 --- /dev/null +++ b/libs/list-types/upper-tribunal-tax-and-chancery-chamber-daily-hearing-list/src/locales/cy.ts @@ -0,0 +1,41 @@ +export const cy = { + pageTitle: "Rhestr Gwrandawiadau Dyddiol Tribiwnlys Uwch Siambr Dreth a Siawnsri", + downloadPdf: "Lawrlwytho fel PDF", + factLinkText: "Dod o hyd i fanylion cyswllt a gwybodaeth arall am lysoedd a thribiwnlysoedd", + factLinkUrl: "https://www.find-court-tribunal.service.gov.uk/", + factAdditionalText: "yng Nghymru a Lloegr, a rhai tribiwnlysoedd sydd heb eu datganoli yn yr Alban.", + listForDate: "Rhestr ar gyfer", + lastUpdated: "Diweddarwyd ddiwethaf", + at: "am", + openingStatement: { + contactText: + "Dylai cynrychiolydd o'r cyfryngau, neu unrhyw berson arall, sy'n dymuno mynychu gwrandawiad o bell gysylltu â uttc@justice.gov.uk a byddwn yn trefnu ar gyfer eich presenoldeb.", + observeLinkText: "Arsylwi gwrandawiad llys neu dribiwnlys fel newyddiadurwr, ymchwilydd neu aelod o'r cyhoedd", + observeLinkUrl: "https://www.gov.uk/guidance/observe-a-court-or-tribunal-hearing" + }, + openingStatementTitle: "Gwybodaeth bwysig", + searchCasesTitle: "Chwilio Achosion", + searchCasesLabel: "Chwilio yn ôl cyfeirnod achos, enw'r achos, barnwr, lleoliad, neu fanylion eraill", + tableHeaders: { + time: "Amser", + caseReferenceNumber: "Rhif cyfeirnod achos", + caseName: "Enw'r achos", + judges: "Barnwr/Barnwyr", + members: "Aelod/Aelodau", + hearingType: "Math o wrandawiad", + venue: "Lleoliad", + additionalInformation: "Gwybodaeth ychwanegol" + }, + dataSource: "Ffynhonnell data", + backToTop: "Yn ôl i frig y dudalen", + cautionNote: + "Noder bod y ddogfen hon yn cynnwys Data Categori Arbennig fel y'i diffinnir yn Neddf Gwarchod Data 2018, a elwid gynt yn Ddata Personol Sensitif, a dylid ei drin yn y ffordd briodol.", + cautionReporting: + "Mae'r ddogfen hon yn cynnwys gwybodaeth a fwriedir i gynorthwyo i roi adroddiad manwl-gywir am achosion llys. Mae'n hanfodol eich bod yn sicrhau eich bod yn gwarchod y Data Categori Arbennig sydd ynddi ac yn cadw at gyfyngiadau adrodd (er enghraifft yn achos dioddefwyr a phlant). Bydd GLlTEF yn rhoi'r gorau i anfon y data os cyfyd pryder ynghylch sut y'i defnyddir.", + provenanceLabels: { + MANUAL_UPLOAD: "Llwytho â Llaw", + XHIBIT: "XHIBIT", + SNL: "SNL", + COMMON_PLATFORM: "Platfform Cyffredin" + } +}; diff --git a/libs/list-types/upper-tribunal-tax-and-chancery-chamber-daily-hearing-list/src/locales/en.ts b/libs/list-types/upper-tribunal-tax-and-chancery-chamber-daily-hearing-list/src/locales/en.ts new file mode 100644 index 000000000..de746a2d1 --- /dev/null +++ b/libs/list-types/upper-tribunal-tax-and-chancery-chamber-daily-hearing-list/src/locales/en.ts @@ -0,0 +1,41 @@ +export const en = { + pageTitle: "Upper Tribunal Tax and Chancery Chamber Daily Hearing List", + downloadPdf: "Download as PDF", + factLinkText: "Find contact details and other information about courts and tribunals", + factLinkUrl: "https://www.find-court-tribunal.service.gov.uk/", + factAdditionalText: "in England and Wales, and some non-devolved tribunals in Scotland.", + listForDate: "List for", + lastUpdated: "Last updated", + at: "at", + openingStatement: { + contactText: + "A representative of the media, or any other person, wishing to attend a remote hearing should contact uttc@justice.gov.uk and we will arrange for your attendance.", + observeLinkText: "Observe a court or tribunal hearing as a journalist, researcher or member of the public", + observeLinkUrl: "https://www.gov.uk/guidance/observe-a-court-or-tribunal-hearing" + }, + openingStatementTitle: "Important information", + searchCasesTitle: "Search Cases", + searchCasesLabel: "Search by case reference, case name, judge, venue, or other details", + tableHeaders: { + time: "Time", + caseReferenceNumber: "Case reference number", + caseName: "Case name", + judges: "Judge(s)", + members: "Member(s)", + hearingType: "Hearing type", + venue: "Venue", + additionalInformation: "Additional information" + }, + dataSource: "Data source", + backToTop: "Back to top", + cautionNote: + "Note this document contains Special Category Data as defined by Data Protection Act 2018, formally known as Sensitive Personal Data, and should be handled appropriately.", + cautionReporting: + "This document contains information intended to assist the accurate reporting of court proceedings. It is vital you ensure that you safeguard the Special Category Data included and abide by reporting restrictions (for example on victims and children). HMCTS will stop sending the data if there is concern about how it will be used.", + provenanceLabels: { + MANUAL_UPLOAD: "Manual Upload", + XHIBIT: "XHIBIT", + SNL: "SNL", + COMMON_PLATFORM: "Common Platform" + } +}; diff --git a/libs/list-types/upper-tribunal-tax-and-chancery-chamber-daily-hearing-list/src/models/types.ts b/libs/list-types/upper-tribunal-tax-and-chancery-chamber-daily-hearing-list/src/models/types.ts new file mode 100644 index 000000000..1b81a3469 --- /dev/null +++ b/libs/list-types/upper-tribunal-tax-and-chancery-chamber-daily-hearing-list/src/models/types.ts @@ -0,0 +1,12 @@ +export interface UtccHearing { + time: string; + caseReferenceNumber: string; + caseName: string; + judges: string; + members: string; + hearingType: string; + venue: string; + additionalInformation: string; +} + +export type UtccHearingList = UtccHearing[]; diff --git a/libs/list-types/upper-tribunal-tax-and-chancery-chamber-daily-hearing-list/src/pdf/pdf-generator.test.ts b/libs/list-types/upper-tribunal-tax-and-chancery-chamber-daily-hearing-list/src/pdf/pdf-generator.test.ts new file mode 100644 index 000000000..20846ac0c --- /dev/null +++ b/libs/list-types/upper-tribunal-tax-and-chancery-chamber-daily-hearing-list/src/pdf/pdf-generator.test.ts @@ -0,0 +1,162 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { mockUploadBlob } = vi.hoisted(() => ({ + mockUploadBlob: vi.fn() +})); + +vi.mock("@hmcts/azure-blob", () => ({ + uploadBlob: mockUploadBlob, + CONTAINER: { ARTEFACT: "artefact", PUBLICATIONS: "publications" } +})); + +vi.mock("@hmcts/pdf-generation", () => ({ + generatePdfFromHtml: vi.fn() +})); + +vi.mock("../rendering/renderer.js", () => ({ + renderUtccDailyHearingListData: vi.fn() +})); + +import { generatePdfFromHtml } from "@hmcts/pdf-generation"; +import { renderUtccDailyHearingListData } from "../rendering/renderer.js"; +import { generateUtccDailyHearingListPdf } from "./pdf-generator.js"; + +const mockRenderedData = { + header: { + listTitle: "Upper Tribunal Tax and Chancery Chamber Daily Hearing List", + hearingDate: "15 January 2025", + lastUpdatedDate: "15 January 2025", + lastUpdatedTime: "9am" + }, + hearings: [] +}; + +const mockHearingList = [ + { + time: "10:00am", + caseReferenceNumber: "UTTC/2025/0001", + caseName: "Smith v HMRC", + judges: "Judge Smith", + members: "Member Jones", + hearingType: "Substantive hearing", + venue: "Field House", + additionalInformation: "" + } +]; + +describe("generateUtccDailyHearingListPdf", () => { + beforeEach(() => { + vi.clearAllMocks(); + + vi.mocked(renderUtccDailyHearingListData).mockReturnValue(mockRenderedData); + mockUploadBlob.mockResolvedValue(undefined); + }); + + it("should generate PDF successfully", async () => { + const pdfBuffer = Buffer.from("PDF content"); + vi.mocked(generatePdfFromHtml).mockResolvedValue({ + success: true, + pdfBuffer, + sizeBytes: 1024 + }); + + const result = await generateUtccDailyHearingListPdf({ + artefactId: "test-artefact-123", + contentDate: new Date("2025-01-15"), + locale: "en", + locationId: "240", + jsonData: mockHearingList + }); + + expect(result.success).toBe(true); + expect(result.pdfPath).toContain("test-artefact-123.pdf"); + expect(result.sizeBytes).toBe(1024); + expect(result.exceedsMaxSize).toBe(false); + }); + + it("should return exceedsMaxSize true when PDF is over 2MB", async () => { + const largePdfBuffer = Buffer.alloc(3 * 1024 * 1024); + vi.mocked(generatePdfFromHtml).mockResolvedValue({ + success: true, + pdfBuffer: largePdfBuffer, + sizeBytes: 3 * 1024 * 1024 + }); + + const result = await generateUtccDailyHearingListPdf({ + artefactId: "large-pdf-123", + contentDate: new Date("2025-01-15"), + locale: "en", + locationId: "240", + jsonData: mockHearingList + }); + + expect(result.success).toBe(true); + expect(result.exceedsMaxSize).toBe(true); + }); + + it("should return error when PDF generation fails", async () => { + vi.mocked(generatePdfFromHtml).mockResolvedValue({ + success: false, + error: "Puppeteer crashed" + }); + + const result = await generateUtccDailyHearingListPdf({ + artefactId: "failed-pdf", + contentDate: new Date("2025-01-15"), + locale: "en", + locationId: "240", + jsonData: mockHearingList + }); + + expect(result.success).toBe(false); + expect(result.error).toBe("Puppeteer crashed"); + }); + + it("should return error when PDF generation reports success but returns no buffer", async () => { + // Arrange + vi.mocked(generatePdfFromHtml).mockResolvedValue({ + success: true, + pdfBuffer: undefined, + sizeBytes: undefined + }); + + // Act + const result = await generateUtccDailyHearingListPdf({ + artefactId: "no-buffer-pdf", + contentDate: new Date("2025-01-15"), + locale: "en", + locationId: "240", + jsonData: mockHearingList + }); + + // Assert + expect(result.success).toBe(false); + expect(result.error).toBe("PDF generation failed"); + }); + + it("should pass correct render options to renderer", async () => { + vi.mocked(generatePdfFromHtml).mockResolvedValue({ + success: true, + pdfBuffer: Buffer.from("PDF"), + sizeBytes: 100 + }); + + const contentDate = new Date("2025-01-15"); + + await generateUtccDailyHearingListPdf({ + artefactId: "test-render-options", + contentDate, + locale: "cy", + locationId: "999", + jsonData: mockHearingList + }); + + expect(renderUtccDailyHearingListData).toHaveBeenCalledWith(mockHearingList, { + locale: "cy", + courtName: "Upper Tribunal Tax and Chancery Chamber", + contentDate, + lastReceivedDate: expect.any(String), + listTitle: "Upper Tribunal Tax and Chancery Chamber Daily Hearing List" + }); + }); +}); diff --git a/libs/list-types/upper-tribunal-tax-and-chancery-chamber-daily-hearing-list/src/pdf/pdf-generator.ts b/libs/list-types/upper-tribunal-tax-and-chancery-chamber-daily-hearing-list/src/pdf/pdf-generator.ts new file mode 100644 index 000000000..9bb5fcadd --- /dev/null +++ b/libs/list-types/upper-tribunal-tax-and-chancery-chamber-daily-hearing-list/src/pdf/pdf-generator.ts @@ -0,0 +1,20 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { generatePdfFromHtml } from "@hmcts/pdf-generation"; +import { PROVENANCE_LABELS } from "@hmcts/publication"; +import { createUtDailyHearingListPdfGenerator } from "@hmcts/upper-tribunal-common"; +import type { UtccHearingList } from "../models/types.js"; +import { renderUtccDailyHearingListData } from "../rendering/renderer.js"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +export const generateUtccDailyHearingListPdf = createUtDailyHearingListPdfGenerator( + "Upper Tribunal Tax and Chancery Chamber", + "Upper Tribunal Tax and Chancery Chamber Daily Hearing List", + renderUtccDailyHearingListData, + () => import("../locales/en.js"), + () => import("../locales/cy.js"), + __dirname, + PROVENANCE_LABELS, + generatePdfFromHtml +); diff --git a/libs/list-types/upper-tribunal-tax-and-chancery-chamber-daily-hearing-list/src/pdf/pdf-template.njk b/libs/list-types/upper-tribunal-tax-and-chancery-chamber-daily-hearing-list/src/pdf/pdf-template.njk new file mode 100644 index 000000000..521c28031 --- /dev/null +++ b/libs/list-types/upper-tribunal-tax-and-chancery-chamber-daily-hearing-list/src/pdf/pdf-template.njk @@ -0,0 +1,67 @@ + + + + + + {{ header.listTitle }} + + + +
+

{{ header.listTitle }}

+ +

{{ t.factLinkText }} {{ t.factAdditionalText }}

+ +

{{ t.listForDate }} {{ header.hearingDate }}

+

{{ t.lastUpdated }} {{ header.lastUpdatedDate }} {{ t.at }} {{ header.lastUpdatedTime }}

+
+ +
+

{{ t.openingStatement.contactText }}

+

{{ t.openingStatement.observeLinkText }}

+
+ + {% if hearings.length > 0 %} + + + + + + + + + + + + + + + {% for hearing in hearings %} + + + + + + + + + + + {% endfor %} + +
{{ t.tableHeaders.time }}{{ t.tableHeaders.caseReferenceNumber }}{{ t.tableHeaders.caseName }}{{ t.tableHeaders.judges }}{{ t.tableHeaders.members }}{{ t.tableHeaders.hearingType }}{{ t.tableHeaders.venue }}{{ t.tableHeaders.additionalInformation }}
{{ hearing.time }}{{ hearing.caseReferenceNumber }}{{ hearing.caseName }}{{ hearing.judges }}{{ hearing.members }}{{ hearing.hearingType }}{{ hearing.venue }}{{ hearing.additionalInformation }}
+ {% else %} +

No hearings scheduled.

+ {% endif %} + + + + diff --git a/libs/list-types/upper-tribunal-tax-and-chancery-chamber-daily-hearing-list/src/rendering/renderer.test.ts b/libs/list-types/upper-tribunal-tax-and-chancery-chamber-daily-hearing-list/src/rendering/renderer.test.ts new file mode 100644 index 000000000..b2fc28a97 --- /dev/null +++ b/libs/list-types/upper-tribunal-tax-and-chancery-chamber-daily-hearing-list/src/rendering/renderer.test.ts @@ -0,0 +1,180 @@ +import { describe, expect, it } from "vitest"; +import type { UtccHearingList } from "../models/types.js"; +import { renderUtccDailyHearingListData } from "./renderer.js"; + +describe("renderUtccDailyHearingListData", () => { + const baseOptions = { + locale: "en", + courtName: "Upper Tribunal Tax and Chancery Chamber", + contentDate: new Date(2025, 0, 15), + lastReceivedDate: "2025-01-15T09:55:00Z", + listTitle: "Upper Tribunal Tax and Chancery Chamber Daily Hearing List" + }; + + it("should render hearing list with header", () => { + const hearingList: UtccHearingList = [ + { + time: "10:00am", + caseReferenceNumber: "UTTC/2025/0001", + caseName: "Smith v HMRC", + judges: "Judge Smith", + members: "Member Jones", + hearingType: "Substantive hearing", + venue: "Field House", + additionalInformation: "Remote hearing" + } + ]; + + const result = renderUtccDailyHearingListData(hearingList, baseOptions); + + expect(result.header.listTitle).toBe("Upper Tribunal Tax and Chancery Chamber Daily Hearing List"); + expect(result.header.hearingDate).toBe("15 January 2025"); + expect(result.header.lastUpdatedDate).toBe("15 January 2025"); + expect(result.header.lastUpdatedTime).toContain("am"); + }); + + it("should render multiple hearings", () => { + const hearingList: UtccHearingList = [ + { + time: "10:00am", + caseReferenceNumber: "UTTC/2025/0001", + caseName: "Smith v HMRC", + judges: "Judge Smith", + members: "Member Jones", + hearingType: "Substantive hearing", + venue: "Field House", + additionalInformation: "" + }, + { + time: "2:00pm", + caseReferenceNumber: "UTTC/2025/0002", + caseName: "Brown v HMRC", + judges: "Judge Brown", + members: "Member Davis", + hearingType: "Preliminary hearing", + venue: "Field House", + additionalInformation: "" + } + ]; + + const result = renderUtccDailyHearingListData(hearingList, baseOptions); + + expect(result.hearings).toHaveLength(2); + expect(result.hearings[0].caseReferenceNumber).toBe("UTTC/2025/0001"); + expect(result.hearings[1].caseReferenceNumber).toBe("UTTC/2025/0002"); + }); + + it("should include additionalInformation in rendered data", () => { + const hearingList: UtccHearingList = [ + { + time: "10:00am", + caseReferenceNumber: "UTTC/2025/0001", + caseName: "Smith v HMRC", + judges: "Judge Smith", + members: "Member Jones", + hearingType: "Substantive hearing", + venue: "Field House", + additionalInformation: "Remote hearing" + } + ]; + + const result = renderUtccDailyHearingListData(hearingList, baseOptions); + + expect(result.hearings[0].additionalInformation).toBe("Remote hearing"); + }); + + it("should handle empty hearing list", () => { + const hearingList: UtccHearingList = []; + + const result = renderUtccDailyHearingListData(hearingList, baseOptions); + + expect(result.hearings).toHaveLength(0); + expect(result.header.listTitle).toBe("Upper Tribunal Tax and Chancery Chamber Daily Hearing List"); + }); + + it("should format AM times correctly", () => { + const hearingList: UtccHearingList = [ + { + time: "10:00am", + caseReferenceNumber: "UTTC/2025/0001", + caseName: "Smith v HMRC", + judges: "Judge Smith", + members: "Member Jones", + hearingType: "Substantive hearing", + venue: "Field House", + additionalInformation: "" + } + ]; + + const options = { ...baseOptions, lastReceivedDate: "2025-01-15T09:55:00Z" }; + const result = renderUtccDailyHearingListData(hearingList, options); + + expect(result.header.lastUpdatedTime).toBe("9:55am"); + }); + + it("should format PM times correctly", () => { + const hearingList: UtccHearingList = [ + { + time: "10:00am", + caseReferenceNumber: "UTTC/2025/0001", + caseName: "Smith v HMRC", + judges: "Judge Smith", + members: "Member Jones", + hearingType: "Substantive hearing", + venue: "Field House", + additionalInformation: "" + } + ]; + + const options = { ...baseOptions, lastReceivedDate: "2025-01-15T14:30:00Z" }; + const result = renderUtccDailyHearingListData(hearingList, options); + + expect(result.header.lastUpdatedTime).toBe("2:30pm"); + }); + + it("should use the provided listTitle", () => { + const hearingList: UtccHearingList = [ + { + time: "10:00am", + caseReferenceNumber: "UTTC/2025/0001", + caseName: "Smith v HMRC", + judges: "Judge Smith", + members: "Member Jones", + hearingType: "Substantive hearing", + venue: "Field House", + additionalInformation: "" + } + ]; + + const welshOptions = { ...baseOptions, listTitle: "Welsh title placeholder" }; + const result = renderUtccDailyHearingListData(hearingList, welshOptions); + + expect(result.header.listTitle).toBe("Welsh title placeholder"); + }); + + it("should preserve all hearing fields without modification", () => { + const hearingList: UtccHearingList = [ + { + time: "10:30am", + caseReferenceNumber: "UTTC/2025/0001/A", + caseName: "Smith & Jones v HMRC", + judges: "Judge Smith; Judge Brown", + members: "Member Jones, Member Davis", + hearingType: "Substantive hearing - final", + venue: "Field House, Room 3", + additionalInformation: "Remote via CVP; contact uttc@justice.gov.uk" + } + ]; + + const result = renderUtccDailyHearingListData(hearingList, baseOptions); + + expect(result.hearings[0].time).toBe("10:30am"); + expect(result.hearings[0].caseReferenceNumber).toBe("UTTC/2025/0001/A"); + expect(result.hearings[0].caseName).toBe("Smith & Jones v HMRC"); + expect(result.hearings[0].judges).toBe("Judge Smith; Judge Brown"); + expect(result.hearings[0].members).toBe("Member Jones, Member Davis"); + expect(result.hearings[0].hearingType).toBe("Substantive hearing - final"); + expect(result.hearings[0].venue).toBe("Field House, Room 3"); + expect(result.hearings[0].additionalInformation).toBe("Remote via CVP; contact uttc@justice.gov.uk"); + }); +}); diff --git a/libs/list-types/upper-tribunal-tax-and-chancery-chamber-daily-hearing-list/src/rendering/renderer.ts b/libs/list-types/upper-tribunal-tax-and-chancery-chamber-daily-hearing-list/src/rendering/renderer.ts new file mode 100644 index 000000000..64f764ad0 --- /dev/null +++ b/libs/list-types/upper-tribunal-tax-and-chancery-chamber-daily-hearing-list/src/rendering/renderer.ts @@ -0,0 +1,46 @@ +import { formatDisplayDate, formatLastUpdatedDateTime } from "@hmcts/list-types-common"; +import type { UtccHearing, UtccHearingList } from "../models/types.js"; + +export interface RenderOptions { + locale: string; + courtName: string; + contentDate: Date; + lastReceivedDate: string; + listTitle: string; +} + +export interface RenderedData { + header: { + listTitle: string; + hearingDate: string; + lastUpdatedDate: string; + lastUpdatedTime: string; + }; + hearings: UtccHearing[]; +} + +export function renderUtccDailyHearingListData(hearingList: UtccHearingList, options: RenderOptions): RenderedData { + const hearingDate = formatDisplayDate(options.contentDate, options.locale); + const { date: lastUpdatedDate, time: lastUpdatedTime } = formatLastUpdatedDateTime(options.lastReceivedDate, options.locale); + + const renderedHearings = hearingList.map((hearing) => ({ + time: hearing.time, + caseReferenceNumber: hearing.caseReferenceNumber, + caseName: hearing.caseName, + judges: hearing.judges, + members: hearing.members, + hearingType: hearing.hearingType, + venue: hearing.venue, + additionalInformation: hearing.additionalInformation + })); + + return { + header: { + listTitle: options.listTitle, + hearingDate, + lastUpdatedDate, + lastUpdatedTime + }, + hearings: renderedHearings + }; +} diff --git a/libs/list-types/upper-tribunal-tax-and-chancery-chamber-daily-hearing-list/src/schemas/upper-tribunal-tax-and-chancery-chamber-daily-hearing-list.json b/libs/list-types/upper-tribunal-tax-and-chancery-chamber-daily-hearing-list/src/schemas/upper-tribunal-tax-and-chancery-chamber-daily-hearing-list.json new file mode 100644 index 000000000..c5c212be9 --- /dev/null +++ b/libs/list-types/upper-tribunal-tax-and-chancery-chamber-daily-hearing-list/src/schemas/upper-tribunal-tax-and-chancery-chamber-daily-hearing-list.json @@ -0,0 +1,68 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Root", + "type": "array", + "items": { + "title": "Hearing list", + "type": "object", + "required": ["time", "caseReferenceNumber", "caseName", "judges", "members", "hearingType", "venue", "additionalInformation"], + "properties": { + "time": { + "title": "Time", + "type": "string", + "default": "", + "examples": ["10am"], + "pattern": "^\\d{1,2}([:.]\\d{2})?[ap]m\\s*$" + }, + "caseReferenceNumber": { + "title": "Case reference number", + "type": "string", + "default": "", + "examples": ["12345"], + "pattern": "^(?!(.|\\r|\\n)*<[^>]+>)(.|\\r|\\n)*$" + }, + "caseName": { + "title": "Case name", + "type": "string", + "default": "", + "examples": ["Case name 1"], + "pattern": "^(?!(.|\\r|\\n)*<[^>]+>)(.|\\r|\\n)*$" + }, + "judges": { + "title": "Judges", + "type": "string", + "default": "", + "examples": ["Judge 1"], + "pattern": "^(?!(.|\\r|\\n)*<[^>]+>)(.|\\r|\\n)*$" + }, + "members": { + "title": "Members", + "type": "string", + "default": "", + "examples": ["Member 1"], + "pattern": "^(?!(.|\\r|\\n)*<[^>]+>)(.|\\r|\\n)*$" + }, + "hearingType": { + "title": "Hearing type", + "type": "string", + "default": "", + "examples": ["Hearing type 1"], + "pattern": "^(?!(.|\\r|\\n)*<[^>]+>)(.|\\r|\\n)*$" + }, + "venue": { + "title": "Venue", + "type": "string", + "default": "", + "examples": ["Venue 1"], + "pattern": "^(?!(.|\\r|\\n)*<[^>]+>)(.|\\r|\\n)*$" + }, + "additionalInformation": { + "title": "Additional information", + "type": "string", + "default": "", + "examples": ["This is additional information"], + "pattern": "^(?!(.|\\r|\\n)*<[^>]+>)(.|\\r|\\n)*$" + } + } + } +} diff --git a/libs/list-types/upper-tribunal-tax-and-chancery-chamber-daily-hearing-list/src/validation/json-validator.test.ts b/libs/list-types/upper-tribunal-tax-and-chancery-chamber-daily-hearing-list/src/validation/json-validator.test.ts new file mode 100644 index 000000000..3f34dada4 --- /dev/null +++ b/libs/list-types/upper-tribunal-tax-and-chancery-chamber-daily-hearing-list/src/validation/json-validator.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it, vi } from "vitest"; + +vi.mock("@hmcts/publication", () => ({ + validateJson: vi.fn() +})); + +import { validateJson } from "@hmcts/publication"; +import { validateUtTaxAndChanceryChamberDailyHearingList } from "./json-validator.js"; + +describe("validateUtTaxAndChanceryChamberDailyHearingList", () => { + it("should call validateJson with the provided data and schema and return the result", () => { + // Arrange + const mockData = { hearings: [] }; + const mockResult = { isValid: true, errors: [] }; + vi.mocked(validateJson).mockReturnValue(mockResult as any); + + // Act + const result = validateUtTaxAndChanceryChamberDailyHearingList(mockData); + + // Assert + expect(validateJson).toHaveBeenCalledWith(mockData, expect.any(Object), "1.0"); + expect(result).toBe(mockResult); + }); + + it("should return the validation result when data is invalid", () => { + // Arrange + const mockData = { invalid: true }; + const mockResult = { isValid: false, errors: ["Missing required field"] }; + vi.mocked(validateJson).mockReturnValue(mockResult as any); + + // Act + const result = validateUtTaxAndChanceryChamberDailyHearingList(mockData); + + // Assert + expect(validateJson).toHaveBeenCalledWith(mockData, expect.any(Object), "1.0"); + expect(result).toBe(mockResult); + }); +}); diff --git a/libs/list-types/upper-tribunal-tax-and-chancery-chamber-daily-hearing-list/src/validation/json-validator.ts b/libs/list-types/upper-tribunal-tax-and-chancery-chamber-daily-hearing-list/src/validation/json-validator.ts new file mode 100644 index 000000000..5d3bfce19 --- /dev/null +++ b/libs/list-types/upper-tribunal-tax-and-chancery-chamber-daily-hearing-list/src/validation/json-validator.ts @@ -0,0 +1,6 @@ +import { type ValidationResult, validateJson } from "@hmcts/publication"; +import schema from "../schemas/upper-tribunal-tax-and-chancery-chamber-daily-hearing-list.json" with { type: "json" }; + +export function validateUtTaxAndChanceryChamberDailyHearingList(jsonData: unknown): ValidationResult { + return validateJson(jsonData, schema, "1.0"); +} diff --git a/libs/list-types/upper-tribunal-tax-and-chancery-chamber-daily-hearing-list/tsconfig.json b/libs/list-types/upper-tribunal-tax-and-chancery-chamber-daily-hearing-list/tsconfig.json new file mode 100644 index 000000000..a029ee937 --- /dev/null +++ b/libs/list-types/upper-tribunal-tax-and-chancery-chamber-daily-hearing-list/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "declaration": true, + "declarationMap": true, + "resolveJsonModule": true + }, + "include": ["src/**/*"], + "exclude": ["**/*.test.ts", "**/*.spec.ts", "dist", "node_modules", "src/assets/"] +} diff --git a/libs/location/src/list-type-data.ts b/libs/location/src/list-type-data.ts index 773ae9dad..20f722cfe 100644 --- a/libs/location/src/list-type-data.ts +++ b/libs/location/src/list-type-data.ts @@ -664,5 +664,41 @@ export const listTypeData: ListTypeData[] = [ isNonStrategic: false, defaultSensitivity: "Classified", subJurisdictionIds: [7] + }, + { + id: 57, + name: "UT_TAX_AND_CHANCERY_CHAMBER_DAILY_HEARING_LIST", + englishFriendlyName: "Upper Tribunal Tax and Chancery Chamber Daily Hearing List", + welshFriendlyName: "Rhestr Gwrandawiadau Dyddiol Tribiwnlys Uwch Siambr Dreth a Siawnsri", + provenance: "MANUAL_UPLOAD", + urlPath: "upper-tribunal-tax-and-chancery-chamber-daily-hearing-list", + isNonStrategic: true, + defaultSensitivity: "Public", + shortenedFriendlyName: "UT (T and CC) Daily Hearing List", + subJurisdictionIds: [30] + }, + { + id: 58, + name: "UT_LANDS_CHAMBER_DAILY_HEARING_LIST", + englishFriendlyName: "Upper Tribunal (Lands Chamber) Daily Hearing List", + welshFriendlyName: "Rhestr Gwrandawiadau Dyddiol Tribiwnlys Uwch (Siambr Tiroedd)", + provenance: "MANUAL_UPLOAD", + urlPath: "upper-tribunal-lands-chamber-daily-hearing-list", + isNonStrategic: true, + defaultSensitivity: "Public", + shortenedFriendlyName: "UT (LC) Daily Hearing List", + subJurisdictionIds: [29] + }, + { + id: 59, + name: "UT_ADMINISTRATIVE_APPEALS_CHAMBER_DAILY_HEARING_LIST", + englishFriendlyName: "Upper Tribunal (Administrative Appeals Chamber) Daily Hearing List", + welshFriendlyName: "Rhestr Gwrandawiadau Dyddiol Tribiwnlys Uwch (Siambr Apeliadau Gweinyddol)", + provenance: "MANUAL_UPLOAD", + urlPath: "upper-tribunal-administrative-appeals-chamber-daily-hearing-list", + isNonStrategic: true, + defaultSensitivity: "Public", + shortenedFriendlyName: "UT (AAC) Daily Hearing List", + subJurisdictionIds: [26] } ]; diff --git a/libs/location/src/seed-data.test.ts b/libs/location/src/seed-data.test.ts index 6a0684433..c51f1a22d 100644 --- a/libs/location/src/seed-data.test.ts +++ b/libs/location/src/seed-data.test.ts @@ -142,13 +142,21 @@ describe("seed-data", () => { mockPrisma.jurisdiction.count.mockResolvedValue(0); mockPrisma.subJurisdiction.count.mockResolvedValue(0); mockPrisma.location.count.mockResolvedValue(0); + mockPrisma.region.upsert.mockResolvedValue({}); + mockPrisma.jurisdiction.upsert.mockResolvedValue({}); + mockPrisma.subJurisdiction.upsert.mockResolvedValue({}); + mockPrisma.location.upsert.mockResolvedValue({}); const { seedLocationData } = await import("./seed-data.js"); await seedLocationData(); expect(consoleLogSpy).toHaveBeenCalledWith("Checking if location data seeding is needed..."); expect(consoleLogSpy).toHaveBeenCalledWith("Skipping seed: Tables already contain data"); - expect(mockPrisma.region.upsert).not.toHaveBeenCalled(); + expect(mockPrisma.region.upsert).toHaveBeenCalledTimes(mockLocationData.regions.length); + expect(mockPrisma.jurisdiction.upsert).toHaveBeenCalledTimes(mockLocationData.jurisdictions.length); + expect(mockPrisma.subJurisdiction.upsert).toHaveBeenCalledTimes(mockLocationData.subJurisdictions.length); + expect(mockPrisma.location.upsert).toHaveBeenCalledTimes(mockLocationData.locations.length); + expect(mockSeedListTypes).toHaveBeenCalledTimes(1); }); it("should return false when tables already contain data (jurisdiction not empty)", async () => { @@ -158,12 +166,20 @@ describe("seed-data", () => { mockPrisma.jurisdiction.count.mockResolvedValue(1); mockPrisma.subJurisdiction.count.mockResolvedValue(0); mockPrisma.location.count.mockResolvedValue(0); + mockPrisma.region.upsert.mockResolvedValue({}); + mockPrisma.jurisdiction.upsert.mockResolvedValue({}); + mockPrisma.subJurisdiction.upsert.mockResolvedValue({}); + mockPrisma.location.upsert.mockResolvedValue({}); const { seedLocationData } = await import("./seed-data.js"); await seedLocationData(); expect(consoleLogSpy).toHaveBeenCalledWith("Skipping seed: Tables already contain data"); - expect(mockPrisma.region.upsert).not.toHaveBeenCalled(); + expect(mockPrisma.region.upsert).toHaveBeenCalledTimes(mockLocationData.regions.length); + expect(mockPrisma.jurisdiction.upsert).toHaveBeenCalledTimes(mockLocationData.jurisdictions.length); + expect(mockPrisma.subJurisdiction.upsert).toHaveBeenCalledTimes(mockLocationData.subJurisdictions.length); + expect(mockPrisma.location.upsert).toHaveBeenCalledTimes(mockLocationData.locations.length); + expect(mockSeedListTypes).toHaveBeenCalledTimes(1); }); it("should return false when tables already contain data (location not empty)", async () => { @@ -173,12 +189,20 @@ describe("seed-data", () => { mockPrisma.jurisdiction.count.mockResolvedValue(0); mockPrisma.subJurisdiction.count.mockResolvedValue(0); mockPrisma.location.count.mockResolvedValue(1); + mockPrisma.region.upsert.mockResolvedValue({}); + mockPrisma.jurisdiction.upsert.mockResolvedValue({}); + mockPrisma.subJurisdiction.upsert.mockResolvedValue({}); + mockPrisma.location.upsert.mockResolvedValue({}); const { seedLocationData } = await import("./seed-data.js"); await seedLocationData(); expect(consoleLogSpy).toHaveBeenCalledWith("Skipping seed: Tables already contain data"); - expect(mockPrisma.region.upsert).not.toHaveBeenCalled(); + expect(mockPrisma.region.upsert).toHaveBeenCalledTimes(mockLocationData.regions.length); + expect(mockPrisma.jurisdiction.upsert).toHaveBeenCalledTimes(mockLocationData.jurisdictions.length); + expect(mockPrisma.subJurisdiction.upsert).toHaveBeenCalledTimes(mockLocationData.subJurisdictions.length); + expect(mockPrisma.location.upsert).toHaveBeenCalledTimes(mockLocationData.locations.length); + expect(mockSeedListTypes).toHaveBeenCalledTimes(1); }); it("should return true when all tables are empty", async () => { diff --git a/libs/location/src/seed-data.ts b/libs/location/src/seed-data.ts index 1cd158e79..f619413bd 100644 --- a/libs/location/src/seed-data.ts +++ b/libs/location/src/seed-data.ts @@ -38,7 +38,57 @@ export async function seedLocationData() { const needsSeeding = await shouldSeed(); if (!needsSeeding) { - // Seed list types even for existing DBs to pick up new entries added since initial seed + // Upsert all reference data even for existing DBs to pick up new entries added since initial seed + for (const region of locationData.regions) { + await prisma.region.upsert({ + where: { regionId: region.regionId }, + create: { regionId: region.regionId, name: region.name, welshName: region.welshName }, + update: { name: region.name, welshName: region.welshName } + }); + } + + for (const jurisdiction of locationData.jurisdictions) { + await prisma.jurisdiction.upsert({ + where: { jurisdictionId: jurisdiction.jurisdictionId }, + create: { jurisdictionId: jurisdiction.jurisdictionId, name: jurisdiction.name, welshName: jurisdiction.welshName }, + update: { name: jurisdiction.name, welshName: jurisdiction.welshName } + }); + } + + for (const subJurisdiction of locationData.subJurisdictions) { + await prisma.subJurisdiction.upsert({ + where: { subJurisdictionId: subJurisdiction.subJurisdictionId }, + create: { + subJurisdictionId: subJurisdiction.subJurisdictionId, + name: subJurisdiction.name, + welshName: subJurisdiction.welshName, + jurisdictionId: subJurisdiction.jurisdictionId + }, + update: { + name: subJurisdiction.name, + welshName: subJurisdiction.welshName, + jurisdictionId: subJurisdiction.jurisdictionId + } + }); + } + + for (const location of locationData.locations) { + await prisma.location.upsert({ + where: { locationId: location.locationId }, + create: { + locationId: location.locationId, + name: location.name, + welshName: location.welshName, + email: null, + contactNo: null + }, + update: { + name: location.name, + welshName: location.welshName + } + }); + } + await seedListTypes(); return; } diff --git a/libs/notifications/package.json b/libs/notifications/package.json index b3fbc19f4..4c11b404b 100644 --- a/libs/notifications/package.json +++ b/libs/notifications/package.json @@ -42,6 +42,9 @@ "@hmcts/send-daily-hearing-list": "workspace:*", "@hmcts/siac-poac-paac-weekly-hearing-list": "workspace:*", "@hmcts/sscs-daily-hearing-list": "workspace:*", + "@hmcts/upper-tribunal-administrative-appeals-chamber-daily-hearing-list": "workspace:*", + "@hmcts/upper-tribunal-lands-chamber-daily-hearing-list": "workspace:*", + "@hmcts/upper-tribunal-tax-and-chancery-chamber-daily-hearing-list": "workspace:*", "@hmcts/utiac-jr-daily-hearing-list": "workspace:*", "@hmcts/utiac-statutory-appeal-daily-hearing-list": "workspace:*", "@hmcts/wpafcc-weekly-hearing-list": "workspace:*", diff --git a/libs/notifications/src/notification/notification-service.ts b/libs/notifications/src/notification/notification-service.ts index 7d66173c1..020c2e1d9 100644 --- a/libs/notifications/src/notification/notification-service.ts +++ b/libs/notifications/src/notification/notification-service.ts @@ -53,6 +53,18 @@ import { formatCaseSummaryForEmail as formatSiacPoacPaacSummaryForEmail } from "@hmcts/siac-poac-paac-weekly-hearing-list"; import { extractCaseSummary as extractSscsSummary, formatCaseSummaryForEmail as formatSscsSummaryForEmail } from "@hmcts/sscs-daily-hearing-list"; +import { + extractCaseSummary as extractUtaacSummary, + formatCaseSummaryForEmail as formatUtaacSummaryForEmail +} from "@hmcts/upper-tribunal-administrative-appeals-chamber-daily-hearing-list"; +import { + extractCaseSummary as extractUtlcSummary, + formatCaseSummaryForEmail as formatUtlcSummaryForEmail +} from "@hmcts/upper-tribunal-lands-chamber-daily-hearing-list"; +import { + extractCaseSummary as extractUtccSummary, + formatCaseSummaryForEmail as formatUtccSummaryForEmail +} from "@hmcts/upper-tribunal-tax-and-chancery-chamber-daily-hearing-list"; import { extractCaseSummary as extractUtiacJrLeedsSummary, extractLondonCaseSummary as extractUtiacJrLondonSummary, @@ -237,6 +249,18 @@ const EMAIL_BUILDER_REGISTRY: Partial> = { extract: extractUtiacJrLeedsSummary as SummaryExtractor, format: formatUtiacJrLeedsSummaryForEmail }, + UT_TAX_AND_CHANCERY_CHAMBER_DAILY_HEARING_LIST: { + extract: extractUtccSummary as SummaryExtractor, + format: formatUtccSummaryForEmail + }, + UT_LANDS_CHAMBER_DAILY_HEARING_LIST: { + extract: extractUtlcSummary as SummaryExtractor, + format: formatUtlcSummaryForEmail + }, + UT_ADMINISTRATIVE_APPEALS_CHAMBER_DAILY_HEARING_LIST: { + extract: extractUtaacSummary as SummaryExtractor, + format: formatUtaacSummaryForEmail + }, MAGISTRATES_STANDARD_LIST: { extract: extractMagistratesStandardSummary as SummaryExtractor, format: formatMagistratesStandardSummaryForEmail diff --git a/libs/public-pages/src/routes/pdf/[artefactId]/download.test.ts b/libs/public-pages/src/routes/pdf/[artefactId]/download.test.ts new file mode 100644 index 000000000..e2d4c75ed --- /dev/null +++ b/libs/public-pages/src/routes/pdf/[artefactId]/download.test.ts @@ -0,0 +1,188 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { mockReadFile } = vi.hoisted(() => { + return { mockReadFile: vi.fn() }; +}); + +vi.mock("node:fs/promises", () => ({ + default: { + readFile: mockReadFile + } +})); + +vi.mock("@hmcts/publication", () => ({ + getArtefactById: vi.fn() +})); + +import { getArtefactById } from "@hmcts/publication"; +import type { Request, Response } from "express"; +import { GET } from "./download.js"; + +const VALID_UUID = "123e4567-e89b-12d3-a456-426614174000"; + +function buildRequest(artefactId: string | string[] | undefined): Partial { + return { + params: { artefactId: artefactId as string } + }; +} + +function buildResponse(): Partial { + return { + status: vi.fn().mockReturnThis(), + json: vi.fn(), + send: vi.fn(), + setHeader: vi.fn(), + locals: {} + }; +} + +function buildArtefact(displayFrom: Date, displayTo: Date) { + return { id: VALID_UUID, displayFrom, displayTo }; +} + +describe("GET /pdf/:artefactId/download", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("artefactId validation", () => { + it("should return 400 when artefactId is undefined", async () => { + // Arrange + const req = buildRequest(undefined); + const res = buildResponse(); + + // Act + await GET(req as Request, res as Response); + + // Assert + expect(res.setHeader).toHaveBeenCalledWith("Cache-Control", "private, max-age=0, no-cache, no-store, must-revalidate"); + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ error: "Invalid request" }); + }); + + it("should return 400 when artefactId fails UUID regex", async () => { + // Arrange + const req = buildRequest("not-a-uuid"); + const res = buildResponse(); + + // Act + await GET(req as Request, res as Response); + + // Assert + expect(res.setHeader).toHaveBeenCalledWith("Cache-Control", "private, max-age=0, no-cache, no-store, must-revalidate"); + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ error: "Invalid request" }); + }); + + it("should normalise an array artefactId and proceed with the first element", async () => { + // Arrange + const past = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000); + const future = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); + vi.mocked(getArtefactById).mockResolvedValue(buildArtefact(past, future)); + mockReadFile.mockResolvedValue(Buffer.from("%PDF")); + + const req = { params: { artefactId: [VALID_UUID] as unknown as string } }; + const res = buildResponse(); + + // Act + await GET(req as Request, res as Response); + + // Assert + expect(getArtefactById).toHaveBeenCalledWith(VALID_UUID); + expect(res.send).toHaveBeenCalled(); + }); + }); + + describe("artefact lookup", () => { + it("should return 404 when artefact is not found", async () => { + // Arrange + vi.mocked(getArtefactById).mockResolvedValue(null); + const req = buildRequest(VALID_UUID); + const res = buildResponse(); + + // Act + await GET(req as Request, res as Response); + + // Assert + expect(res.setHeader).toHaveBeenCalledWith("Cache-Control", "private, max-age=0, no-cache, no-store, must-revalidate"); + expect(res.status).toHaveBeenCalledWith(404); + expect(res.json).toHaveBeenCalledWith({ error: "Artefact not found" }); + }); + }); + + describe("date window validation", () => { + it("should return 410 when current date is before displayFrom", async () => { + // Arrange + const displayFrom = new Date(Date.now() + 24 * 60 * 60 * 1000); // tomorrow + const displayTo = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // next week + vi.mocked(getArtefactById).mockResolvedValue(buildArtefact(displayFrom, displayTo)); + const req = buildRequest(VALID_UUID); + const res = buildResponse(); + + // Act + await GET(req as Request, res as Response); + + // Assert + expect(res.setHeader).toHaveBeenCalledWith("Cache-Control", "private, max-age=0, no-cache, no-store, must-revalidate"); + expect(res.status).toHaveBeenCalledWith(410); + expect(res.json).toHaveBeenCalledWith({ error: "File has expired" }); + }); + + it("should return 410 when current date is after displayTo", async () => { + // Arrange + const displayFrom = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000); // last week + const displayTo = new Date(Date.now() - 24 * 60 * 60 * 1000); // yesterday + vi.mocked(getArtefactById).mockResolvedValue(buildArtefact(displayFrom, displayTo)); + const req = buildRequest(VALID_UUID); + const res = buildResponse(); + + // Act + await GET(req as Request, res as Response); + + // Assert + expect(res.setHeader).toHaveBeenCalledWith("Cache-Control", "private, max-age=0, no-cache, no-store, must-revalidate"); + expect(res.status).toHaveBeenCalledWith(410); + expect(res.json).toHaveBeenCalledWith({ error: "File has expired" }); + }); + }); + + describe("file serving", () => { + it("should return the PDF with correct headers when the file exists and dates are valid", async () => { + // Arrange + const displayFrom = new Date(Date.now() - 24 * 60 * 60 * 1000); // yesterday + const displayTo = new Date(Date.now() + 24 * 60 * 60 * 1000); // tomorrow + vi.mocked(getArtefactById).mockResolvedValue(buildArtefact(displayFrom, displayTo)); + const pdfBuffer = Buffer.from("%PDF-1.4 test content"); + mockReadFile.mockResolvedValue(pdfBuffer); + const req = buildRequest(VALID_UUID); + const res = buildResponse(); + + // Act + await GET(req as Request, res as Response); + + // Assert + expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "application/pdf"); + expect(res.setHeader).toHaveBeenCalledWith("Content-Disposition", `inline; filename="${VALID_UUID}.pdf"`); + expect(res.setHeader).toHaveBeenCalledWith("Cache-Control", "private, max-age=0, no-cache, no-store, must-revalidate"); + expect(res.send).toHaveBeenCalledWith(pdfBuffer); + }); + + it("should return 404 when the PDF file is not found on disk", async () => { + // Arrange + const displayFrom = new Date(Date.now() - 24 * 60 * 60 * 1000); // yesterday + const displayTo = new Date(Date.now() + 24 * 60 * 60 * 1000); // tomorrow + vi.mocked(getArtefactById).mockResolvedValue(buildArtefact(displayFrom, displayTo)); + mockReadFile.mockRejectedValue(Object.assign(new Error("ENOENT"), { code: "ENOENT" })); + const req = buildRequest(VALID_UUID); + const res = buildResponse(); + + // Act + await GET(req as Request, res as Response); + + // Assert + expect(res.setHeader).toHaveBeenCalledWith("Cache-Control", "private, max-age=0, no-cache, no-store, must-revalidate"); + expect(res.status).toHaveBeenCalledWith(404); + expect(res.json).toHaveBeenCalledWith({ error: "PDF not found" }); + }); + }); +}); diff --git a/libs/public-pages/src/routes/pdf/[artefactId]/download.ts b/libs/public-pages/src/routes/pdf/[artefactId]/download.ts new file mode 100644 index 000000000..3a0e0d953 --- /dev/null +++ b/libs/public-pages/src/routes/pdf/[artefactId]/download.ts @@ -0,0 +1,54 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { getArtefactById } from "@hmcts/publication"; +import type { Request, Response } from "express"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const MONOREPO_ROOT = path.join(__dirname, "..", "..", "..", "..", "..", ".."); +const STORAGE_BASE = path.join(MONOREPO_ROOT, "storage", "temp", "uploads"); + +const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +export const GET = async (req: Request, res: Response) => { + const artefactId = req.params.artefactId; + const id = Array.isArray(artefactId) ? artefactId[0] : artefactId; + + if (!id || !UUID_REGEX.test(id)) { + res.setHeader("Cache-Control", "private, max-age=0, no-cache, no-store, must-revalidate"); + return res.status(400).json({ error: "Invalid request" }); + } + + const artefact = await getArtefactById(id); + + if (!artefact) { + res.setHeader("Cache-Control", "private, max-age=0, no-cache, no-store, must-revalidate"); + return res.status(404).json({ error: "Artefact not found" }); + } + + const now = new Date(); + if (now < artefact.displayFrom || now > artefact.displayTo) { + res.setHeader("Cache-Control", "private, max-age=0, no-cache, no-store, must-revalidate"); + return res.status(410).json({ error: "File has expired" }); + } + + const pdfPath = path.join(STORAGE_BASE, `${id}.pdf`); + const resolvedPath = path.resolve(pdfPath); + const resolvedBase = path.resolve(STORAGE_BASE); + + if (!resolvedPath.startsWith(resolvedBase + path.sep)) { + return res.status(400).json({ error: "Invalid request" }); + } + + try { + const pdfBuffer = await fs.readFile(resolvedPath); + res.setHeader("Content-Type", "application/pdf"); + res.setHeader("Content-Disposition", `inline; filename="${id}.pdf"`); + res.setHeader("Cache-Control", "private, max-age=0, no-cache, no-store, must-revalidate"); + return res.send(pdfBuffer); + } catch { + res.setHeader("Cache-Control", "private, max-age=0, no-cache, no-store, must-revalidate"); + return res.status(404).json({ error: "PDF not found" }); + } +}; diff --git a/libs/publication/package.json b/libs/publication/package.json index a5ae7e7d9..d9cdcbc63 100644 --- a/libs/publication/package.json +++ b/libs/publication/package.json @@ -49,6 +49,9 @@ "@hmcts/send-daily-hearing-list": "workspace:*", "@hmcts/siac-poac-paac-weekly-hearing-list": "workspace:*", "@hmcts/sscs-daily-hearing-list": "workspace:*", + "@hmcts/upper-tribunal-administrative-appeals-chamber-daily-hearing-list": "workspace:*", + "@hmcts/upper-tribunal-lands-chamber-daily-hearing-list": "workspace:*", + "@hmcts/upper-tribunal-tax-and-chancery-chamber-daily-hearing-list": "workspace:*", "@hmcts/utiac-jr-daily-hearing-list": "workspace:*", "@hmcts/utiac-statutory-appeal-daily-hearing-list": "workspace:*", "@hmcts/web-core": "workspace:*", diff --git a/libs/publication/src/processing/service.test.ts b/libs/publication/src/processing/service.test.ts index 157a46943..9054f7621 100644 --- a/libs/publication/src/processing/service.test.ts +++ b/libs/publication/src/processing/service.test.ts @@ -26,6 +26,18 @@ vi.mock("@hmcts/ast-daily-hearing-list", () => ({ generateAstDailyHearingListPdf: vi.fn() })); +vi.mock("@hmcts/upper-tribunal-administrative-appeals-chamber-daily-hearing-list", () => ({ + generateUtaacDailyHearingListPdf: vi.fn() +})); + +vi.mock("@hmcts/upper-tribunal-lands-chamber-daily-hearing-list", () => ({ + generateUtlcDailyHearingListPdf: vi.fn() +})); + +vi.mock("@hmcts/upper-tribunal-tax-and-chancery-chamber-daily-hearing-list", () => ({ + generateUtccDailyHearingListPdf: vi.fn() +})); + vi.mock("@hmcts/civil-and-family-daily-cause-list", () => ({ generateCauseListPdf: vi.fn() })); @@ -107,6 +119,9 @@ vi.mock("../artefact-search-extractor.js", () => ({ describe("publication-processor", async () => { const { generateCareStandardsTribunalWeeklyHearingListPdf } = await import("@hmcts/care-standards-tribunal-weekly-hearing-list"); const { generateSscsDailyHearingListPdf } = await import("@hmcts/sscs-daily-hearing-list"); + const { generateUtaacDailyHearingListPdf } = await import("@hmcts/upper-tribunal-administrative-appeals-chamber-daily-hearing-list"); + const { generateUtlcDailyHearingListPdf } = await import("@hmcts/upper-tribunal-lands-chamber-daily-hearing-list"); + const { generateUtccDailyHearingListPdf } = await import("@hmcts/upper-tribunal-tax-and-chancery-chamber-daily-hearing-list"); const { generateCauseListPdf } = await import("@hmcts/civil-and-family-daily-cause-list"); const { generateCourtOfAppealCivilDailyCauseListPdf } = await import("@hmcts/court-of-appeal-civil-daily-cause-list"); const { generateLondonAdministrativeCourtDailyCauseListPdf } = await import("@hmcts/london-administrative-court-daily-cause-list"); @@ -570,6 +585,60 @@ describe("publication-processor", async () => { expect(result).toEqual({ pdfPath: "/path/to/utiac-leeds-pdf", sizeBytes: 2048, exceedsMaxSize: false }); }); + it("should generate PDF for UT Tax and Chancery Chamber Daily Hearing List", async () => { + vi.mocked(prisma.listType.findUnique).mockResolvedValue({ + name: "UT_TAX_AND_CHANCERY_CHAMBER_DAILY_HEARING_LIST", + friendlyName: "Upper Tribunal Tax and Chancery Chamber Daily Hearing List" + } as any); + vi.mocked(generateUtccDailyHearingListPdf).mockResolvedValue({ + success: true, + pdfPath: "test.pdf", + sizeBytes: 1024, + exceedsMaxSize: false + }); + + const result = await generatePublicationPdf({ ...baseParams, listTypeId: 12 }); + + expect(generateUtccDailyHearingListPdf).toHaveBeenCalled(); + expect(result).toEqual({ pdfPath: "test.pdf", sizeBytes: 1024, exceedsMaxSize: false }); + }); + + it("should generate PDF for UT Lands Chamber Daily Hearing List", async () => { + vi.mocked(prisma.listType.findUnique).mockResolvedValue({ + name: "UT_LANDS_CHAMBER_DAILY_HEARING_LIST", + friendlyName: "Upper Tribunal Lands Chamber Daily Hearing List" + } as any); + vi.mocked(generateUtlcDailyHearingListPdf).mockResolvedValue({ + success: true, + pdfPath: "test.pdf", + sizeBytes: 1024, + exceedsMaxSize: false + }); + + const result = await generatePublicationPdf({ ...baseParams, listTypeId: 13 }); + + expect(generateUtlcDailyHearingListPdf).toHaveBeenCalled(); + expect(result).toEqual({ pdfPath: "test.pdf", sizeBytes: 1024, exceedsMaxSize: false }); + }); + + it("should generate PDF for UT Administrative Appeals Chamber Daily Hearing List", async () => { + vi.mocked(prisma.listType.findUnique).mockResolvedValue({ + name: "UT_ADMINISTRATIVE_APPEALS_CHAMBER_DAILY_HEARING_LIST", + friendlyName: "Upper Tribunal Administrative Appeals Chamber Daily Hearing List" + } as any); + vi.mocked(generateUtaacDailyHearingListPdf).mockResolvedValue({ + success: true, + pdfPath: "test.pdf", + sizeBytes: 1024, + exceedsMaxSize: false + }); + + const result = await generatePublicationPdf({ ...baseParams, listTypeId: 14 }); + + expect(generateUtaacDailyHearingListPdf).toHaveBeenCalled(); + expect(result).toEqual({ pdfPath: "test.pdf", sizeBytes: 1024, exceedsMaxSize: false }); + }); + it("should generate PDF for RCJ Standard Daily Cause List via rcjStandardGenerator", async () => { vi.mocked(prisma.listType.findUnique).mockResolvedValue({ name: "CIVIL_COURTS_RCJ_DAILY_CAUSE_LIST", diff --git a/libs/publication/src/processing/service.ts b/libs/publication/src/processing/service.ts index 199ff4c40..75ff72c18 100644 --- a/libs/publication/src/processing/service.ts +++ b/libs/publication/src/processing/service.ts @@ -24,6 +24,9 @@ import { generateRcjStandardDailyCauseListPdf, type StandardHearingList } from " import { generateSendDailyHearingListPdf, type SendDailyHearingList } from "@hmcts/send-daily-hearing-list"; import { generateSiacPoacPaacWeeklyHearingListPdf, type SiacPoacPaacHearingList } from "@hmcts/siac-poac-paac-weekly-hearing-list"; import { generateSscsDailyHearingListPdf, importantInformationByListType, type SscsDailyHearingList } from "@hmcts/sscs-daily-hearing-list"; +import { generateUtaacDailyHearingListPdf, type UtaacHearingList } from "@hmcts/upper-tribunal-administrative-appeals-chamber-daily-hearing-list"; +import { generateUtlcDailyHearingListPdf, type UtlcHearingList } from "@hmcts/upper-tribunal-lands-chamber-daily-hearing-list"; +import { generateUtccDailyHearingListPdf, type UtccHearingList } from "@hmcts/upper-tribunal-tax-and-chancery-chamber-daily-hearing-list"; import { createUtiacJrDailyHearingListPdfGenerator, generateUtiacJrLeedsDailyHearingListPdf, @@ -269,6 +272,9 @@ const PDF_GENERATOR_REGISTRY: Partial> = { jsonData: p.jsonData as UtiacJrHearingList, contentDate: p.contentDate }), + UT_TAX_AND_CHANCERY_CHAMBER_DAILY_HEARING_LIST: (p) => generateUtccDailyHearingListPdf({ ...p, jsonData: p.jsonData as UtccHearingList }), + UT_LANDS_CHAMBER_DAILY_HEARING_LIST: (p) => generateUtlcDailyHearingListPdf({ ...p, jsonData: p.jsonData as UtlcHearingList }), + UT_ADMINISTRATIVE_APPEALS_CHAMBER_DAILY_HEARING_LIST: (p) => generateUtaacDailyHearingListPdf({ ...p, jsonData: p.jsonData as UtaacHearingList }), MAGISTRATES_STANDARD_LIST: (p) => generateMagistratesStandardListPdf({ ...p, jsonData: p.jsonData as MagistratesStandardList }), MAGISTRATES_PUBLIC_LIST: (p) => generateMagistratesPublicListPdf({ ...p, jsonData: p.jsonData as MagistratesPublicListData }) }; diff --git a/tsconfig.json b/tsconfig.json index 05866627d..8d376f220 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -83,6 +83,12 @@ "@hmcts/legacy-third-party-fulfilment": ["libs/legacy-third-party-fulfilment/src"], "@hmcts/test-support": ["libs/test-support/src"], "@hmcts/test-support/config": ["libs/test-support/src/config"], + "@hmcts/upper-tribunal-common": ["libs/list-types/upper-tribunal-common/src"], + "@hmcts/upper-tribunal-tax-and-chancery-chamber-daily-hearing-list": ["libs/list-types/upper-tribunal-tax-and-chancery-chamber-daily-hearing-list/src"], + "@hmcts/upper-tribunal-lands-chamber-daily-hearing-list": ["libs/list-types/upper-tribunal-lands-chamber-daily-hearing-list/src"], + "@hmcts/upper-tribunal-administrative-appeals-chamber-daily-hearing-list": [ + "libs/list-types/upper-tribunal-administrative-appeals-chamber-daily-hearing-list/src" + ], "@hmcts/third-party-user": ["libs/third-party-user/src"], "@hmcts/third-party-user/config": ["libs/third-party-user/src/config"], "@hmcts/sscs-daily-hearing-list": ["libs/list-types/sscs-daily-hearing-list/src"], diff --git a/yarn.lock b/yarn.lock index 82a21b03f..420a0c230 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2713,6 +2713,9 @@ __metadata: "@hmcts/send-daily-hearing-list": "workspace:*" "@hmcts/siac-poac-paac-weekly-hearing-list": "workspace:*" "@hmcts/sscs-daily-hearing-list": "workspace:*" + "@hmcts/upper-tribunal-administrative-appeals-chamber-daily-hearing-list": "workspace:*" + "@hmcts/upper-tribunal-lands-chamber-daily-hearing-list": "workspace:*" + "@hmcts/upper-tribunal-tax-and-chancery-chamber-daily-hearing-list": "workspace:*" "@hmcts/utiac-jr-daily-hearing-list": "workspace:*" "@hmcts/utiac-statutory-appeal-daily-hearing-list": "workspace:*" "@hmcts/wpafcc-weekly-hearing-list": "workspace:*" @@ -2826,6 +2829,9 @@ __metadata: "@hmcts/send-daily-hearing-list": "workspace:*" "@hmcts/siac-poac-paac-weekly-hearing-list": "workspace:*" "@hmcts/sscs-daily-hearing-list": "workspace:*" + "@hmcts/upper-tribunal-administrative-appeals-chamber-daily-hearing-list": "workspace:*" + "@hmcts/upper-tribunal-lands-chamber-daily-hearing-list": "workspace:*" + "@hmcts/upper-tribunal-tax-and-chancery-chamber-daily-hearing-list": "workspace:*" "@hmcts/utiac-jr-daily-hearing-list": "workspace:*" "@hmcts/utiac-statutory-appeal-daily-hearing-list": "workspace:*" "@hmcts/web-core": "workspace:*" @@ -3006,6 +3012,74 @@ __metadata: languageName: unknown linkType: soft +"@hmcts/upper-tribunal-administrative-appeals-chamber-daily-hearing-list@workspace:*, @hmcts/upper-tribunal-administrative-appeals-chamber-daily-hearing-list@workspace:libs/list-types/upper-tribunal-administrative-appeals-chamber-daily-hearing-list": + version: 0.0.0-use.local + resolution: "@hmcts/upper-tribunal-administrative-appeals-chamber-daily-hearing-list@workspace:libs/list-types/upper-tribunal-administrative-appeals-chamber-daily-hearing-list" + dependencies: + "@hmcts/list-types-common": "workspace:*" + "@hmcts/pdf-generation": "workspace:*" + "@hmcts/postgres-prisma": "workspace:*" + "@hmcts/upper-tribunal-common": "workspace:*" + "@types/luxon": "npm:3.7.1" + "@types/node": "npm:24.10.4" + luxon: "npm:3.7.2" + typescript: "npm:6.0.3" + vitest: "npm:4.1.8" + peerDependencies: + express: ^5.1.0 + languageName: unknown + linkType: soft + +"@hmcts/upper-tribunal-common@workspace:*, @hmcts/upper-tribunal-common@workspace:libs/list-types/upper-tribunal-common": + version: 0.0.0-use.local + resolution: "@hmcts/upper-tribunal-common@workspace:libs/list-types/upper-tribunal-common" + dependencies: + "@hmcts/azure-blob": "workspace:*" + "@hmcts/list-types-common": "workspace:*" + "@hmcts/pdf-generation": "workspace:*" + "@types/node": "npm:24.10.4" + nunjucks: "npm:3.2.4" + typescript: "npm:6.0.3" + vitest: "npm:4.1.9" + languageName: unknown + linkType: soft + +"@hmcts/upper-tribunal-lands-chamber-daily-hearing-list@workspace:*, @hmcts/upper-tribunal-lands-chamber-daily-hearing-list@workspace:libs/list-types/upper-tribunal-lands-chamber-daily-hearing-list": + version: 0.0.0-use.local + resolution: "@hmcts/upper-tribunal-lands-chamber-daily-hearing-list@workspace:libs/list-types/upper-tribunal-lands-chamber-daily-hearing-list" + dependencies: + "@hmcts/list-types-common": "workspace:*" + "@hmcts/pdf-generation": "workspace:*" + "@hmcts/postgres-prisma": "workspace:*" + "@hmcts/upper-tribunal-common": "workspace:*" + "@types/luxon": "npm:3.7.1" + "@types/node": "npm:24.10.4" + luxon: "npm:3.7.2" + typescript: "npm:6.0.3" + vitest: "npm:4.1.8" + peerDependencies: + express: ^5.1.0 + languageName: unknown + linkType: soft + +"@hmcts/upper-tribunal-tax-and-chancery-chamber-daily-hearing-list@workspace:*, @hmcts/upper-tribunal-tax-and-chancery-chamber-daily-hearing-list@workspace:libs/list-types/upper-tribunal-tax-and-chancery-chamber-daily-hearing-list": + version: 0.0.0-use.local + resolution: "@hmcts/upper-tribunal-tax-and-chancery-chamber-daily-hearing-list@workspace:libs/list-types/upper-tribunal-tax-and-chancery-chamber-daily-hearing-list" + dependencies: + "@hmcts/list-types-common": "workspace:*" + "@hmcts/pdf-generation": "workspace:*" + "@hmcts/postgres-prisma": "workspace:*" + "@hmcts/upper-tribunal-common": "workspace:*" + "@types/luxon": "npm:3.7.1" + "@types/node": "npm:24.10.4" + luxon: "npm:3.7.2" + typescript: "npm:6.0.3" + vitest: "npm:4.1.8" + peerDependencies: + express: ^5.1.0 + languageName: unknown + linkType: soft + "@hmcts/utiac-jr-daily-hearing-list@workspace:*, @hmcts/utiac-jr-daily-hearing-list@workspace:libs/list-types/utiac-jr-daily-hearing-list": version: 0.0.0-use.local resolution: "@hmcts/utiac-jr-daily-hearing-list@workspace:libs/list-types/utiac-jr-daily-hearing-list" @@ -3113,6 +3187,9 @@ __metadata: "@hmcts/subscriptions": "workspace:*" "@hmcts/system-admin-pages": "workspace:*" "@hmcts/test-support": "workspace:*" + "@hmcts/upper-tribunal-administrative-appeals-chamber-daily-hearing-list": "workspace:*" + "@hmcts/upper-tribunal-lands-chamber-daily-hearing-list": "workspace:*" + "@hmcts/upper-tribunal-tax-and-chancery-chamber-daily-hearing-list": "workspace:*" "@hmcts/web-core": "workspace:*" "@types/config": "npm:4.4.0" "@types/cookie-parser": "npm:1.4.10" @@ -5794,6 +5871,34 @@ __metadata: languageName: node linkType: hard +"@vitest/expect@npm:4.1.8": + version: 4.1.8 + resolution: "@vitest/expect@npm:4.1.8" + dependencies: + "@standard-schema/spec": "npm:^1.1.0" + "@types/chai": "npm:^5.2.2" + "@vitest/spy": "npm:4.1.8" + "@vitest/utils": "npm:4.1.8" + chai: "npm:^6.2.2" + tinyrainbow: "npm:^3.1.0" + checksum: 10c0/f7bf6c720d2427c3bd0b35472ebd84d963be7d09ecf52a0fb05e8c4d5d0c9ee164a8c28eee6360947be1b245b47faefab54560cb98e5cb678c1c1074260b9149 + languageName: node + linkType: hard + +"@vitest/expect@npm:4.1.9": + version: 4.1.9 + resolution: "@vitest/expect@npm:4.1.9" + dependencies: + "@standard-schema/spec": "npm:^1.1.0" + "@types/chai": "npm:^5.2.2" + "@vitest/spy": "npm:4.1.9" + "@vitest/utils": "npm:4.1.9" + chai: "npm:^6.2.2" + tinyrainbow: "npm:^3.1.0" + checksum: 10c0/243bacaed2cba5e0ea4ec7465662fcec465a358a0e06381e337fac49426aa67a73b104fbb9d65d8bccadfba8f70e27f57ffb897aacfa140f579a556367357875 + languageName: node + linkType: hard + "@vitest/mocker@npm:4.1.10": version: 4.1.10 resolution: "@vitest/mocker@npm:4.1.10" @@ -5813,6 +5918,44 @@ __metadata: languageName: node linkType: hard +"@vitest/mocker@npm:4.1.8": + version: 4.1.8 + resolution: "@vitest/mocker@npm:4.1.8" + dependencies: + "@vitest/spy": "npm:4.1.8" + estree-walker: "npm:^3.0.3" + magic-string: "npm:^0.30.21" + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + checksum: 10c0/f8cb2b8b55dc2cba0b2399aeee528b0187042f22cbc2d50a4fd6141f5aa246ebc41700f45dd1d73eca44ddfb57dcde48b2eb317bfbb1198f5ab2cc4fd04b2ea0 + languageName: node + linkType: hard + +"@vitest/mocker@npm:4.1.9": + version: 4.1.9 + resolution: "@vitest/mocker@npm:4.1.9" + dependencies: + "@vitest/spy": "npm:4.1.9" + estree-walker: "npm:^3.0.3" + magic-string: "npm:^0.30.21" + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + checksum: 10c0/707353b7435bbfd441cc754e4ee7bc5921b70d07b051c6e414b6bbe4ca369154702b0ddeb603389469fe87ca1983e002eb2d55044582661f54a1945dd27e5c82 + languageName: node + linkType: hard + "@vitest/pretty-format@npm:4.1.10": version: 4.1.10 resolution: "@vitest/pretty-format@npm:4.1.10" @@ -5822,6 +5965,24 @@ __metadata: languageName: node linkType: hard +"@vitest/pretty-format@npm:4.1.8": + version: 4.1.8 + resolution: "@vitest/pretty-format@npm:4.1.8" + dependencies: + tinyrainbow: "npm:^3.1.0" + checksum: 10c0/553c456692a4b9ae13cd116c234c74b4495e0f1a0d5c51ffc3fab8ea085e3550769967e29db79bdac0cf127b1bf88b7f70bfba3dcc72be6bddf834433e30cc91 + languageName: node + linkType: hard + +"@vitest/pretty-format@npm:4.1.9": + version: 4.1.9 + resolution: "@vitest/pretty-format@npm:4.1.9" + dependencies: + tinyrainbow: "npm:^3.1.0" + checksum: 10c0/5b96295f25ab885616230ad1355fc82f490bebb39cc707688d7c8969c08270d7e076ed8a10af4e762ed57145193c6061a1f549f136f0ded344f8db0c2b3fb3de + languageName: node + linkType: hard + "@vitest/runner@npm:4.1.10": version: 4.1.10 resolution: "@vitest/runner@npm:4.1.10" @@ -5832,6 +5993,26 @@ __metadata: languageName: node linkType: hard +"@vitest/runner@npm:4.1.8": + version: 4.1.8 + resolution: "@vitest/runner@npm:4.1.8" + dependencies: + "@vitest/utils": "npm:4.1.8" + pathe: "npm:^2.0.3" + checksum: 10c0/706808a4b7b95ea9a9268fc152dd39e15a9a754f37c7990aea167486a9094caa913dae454771ae02c18dccfabd667f8cc38eed33a1307a79d32a89878b5bcce1 + languageName: node + linkType: hard + +"@vitest/runner@npm:4.1.9": + version: 4.1.9 + resolution: "@vitest/runner@npm:4.1.9" + dependencies: + "@vitest/utils": "npm:4.1.9" + pathe: "npm:^2.0.3" + checksum: 10c0/d206b4891a64b1f55c346f832b0a7b489108094d8ae34438d3b53e78be7b45b139fa95ffa027c98c357bd532268ee573168de1943235b7eed32a9236ed5978bb + languageName: node + linkType: hard + "@vitest/snapshot@npm:4.1.10": version: 4.1.10 resolution: "@vitest/snapshot@npm:4.1.10" @@ -5844,6 +6025,30 @@ __metadata: languageName: node linkType: hard +"@vitest/snapshot@npm:4.1.8": + version: 4.1.8 + resolution: "@vitest/snapshot@npm:4.1.8" + dependencies: + "@vitest/pretty-format": "npm:4.1.8" + "@vitest/utils": "npm:4.1.8" + magic-string: "npm:^0.30.21" + pathe: "npm:^2.0.3" + checksum: 10c0/ba4c32112491d42d24986f921c50ede5edbdb4b7eafa16c72cf8d2c9ecc44121fdb3d9365236747a9841f0d6776affc6457470fcbb082df9dbc28c24792a0c6d + languageName: node + linkType: hard + +"@vitest/snapshot@npm:4.1.9": + version: 4.1.9 + resolution: "@vitest/snapshot@npm:4.1.9" + dependencies: + "@vitest/pretty-format": "npm:4.1.9" + "@vitest/utils": "npm:4.1.9" + magic-string: "npm:^0.30.21" + pathe: "npm:^2.0.3" + checksum: 10c0/c3099df12ad1f9c1e180441856c9eb82f1990f87ff16aafedd6fa19978eaff20bc59220b692a99fcc822daef86eab256ba3dadb49544b7bd625b57c49cd9d995 + languageName: node + linkType: hard + "@vitest/spy@npm:4.1.10": version: 4.1.10 resolution: "@vitest/spy@npm:4.1.10" @@ -5851,6 +6056,20 @@ __metadata: languageName: node linkType: hard +"@vitest/spy@npm:4.1.8": + version: 4.1.8 + resolution: "@vitest/spy@npm:4.1.8" + checksum: 10c0/3c10c0325a09d16bc0e77c0be96c47c15416186e33332880c0d1dd0a51d51a866091067b81f2a2ef6fb422a7760e6cf15c04d91a0eca4d59f62e8c8401fa53fc + languageName: node + linkType: hard + +"@vitest/spy@npm:4.1.9": + version: 4.1.9 + resolution: "@vitest/spy@npm:4.1.9" + checksum: 10c0/e51f328f55b76e8ba66e5e18f183484a8dc0a092685b101112d3e9fb8e989ddca162c98ddf00254476502c25bc05c4ec1e277fd6ad8bfc702464c08f6b5dd115 + languageName: node + linkType: hard + "@vitest/ui@npm:4.1.10": version: 4.1.10 resolution: "@vitest/ui@npm:4.1.10" @@ -5879,6 +6098,28 @@ __metadata: languageName: node linkType: hard +"@vitest/utils@npm:4.1.8": + version: 4.1.8 + resolution: "@vitest/utils@npm:4.1.8" + dependencies: + "@vitest/pretty-format": "npm:4.1.8" + convert-source-map: "npm:^2.0.0" + tinyrainbow: "npm:^3.1.0" + checksum: 10c0/acda9d3d640c1ebc81afb358ac30589d7d7d583af81e2d09419f0af9cbe41f3ce0b90527326943bf0da51614be5fc31afcd32259f6beb32b3417999d6ef380f3 + languageName: node + linkType: hard + +"@vitest/utils@npm:4.1.9": + version: 4.1.9 + resolution: "@vitest/utils@npm:4.1.9" + dependencies: + "@vitest/pretty-format": "npm:4.1.9" + convert-source-map: "npm:^2.0.0" + tinyrainbow: "npm:^3.1.0" + checksum: 10c0/d55506c077fd72c091eb66f02926f0abf72801c87a085f565698289562f47befa114ae2c680ab8736dfe46abab0cfd6b8031f2ac519bafeb37578aa6e5ad03c5 + languageName: node + linkType: hard + "a-sync-waterfall@npm:^1.0.0": version: 1.0.1 resolution: "a-sync-waterfall@npm:1.0.1" @@ -11323,6 +11564,142 @@ __metadata: languageName: node linkType: hard +"vitest@npm:4.1.8": + version: 4.1.8 + resolution: "vitest@npm:4.1.8" + dependencies: + "@vitest/expect": "npm:4.1.8" + "@vitest/mocker": "npm:4.1.8" + "@vitest/pretty-format": "npm:4.1.8" + "@vitest/runner": "npm:4.1.8" + "@vitest/snapshot": "npm:4.1.8" + "@vitest/spy": "npm:4.1.8" + "@vitest/utils": "npm:4.1.8" + es-module-lexer: "npm:^2.0.0" + expect-type: "npm:^1.3.0" + magic-string: "npm:^0.30.21" + obug: "npm:^2.1.1" + pathe: "npm:^2.0.3" + picomatch: "npm:^4.0.3" + std-env: "npm:^4.0.0-rc.1" + tinybench: "npm:^2.9.0" + tinyexec: "npm:^1.0.2" + tinyglobby: "npm:^0.2.15" + tinyrainbow: "npm:^3.1.0" + vite: "npm:^6.0.0 || ^7.0.0 || ^8.0.0" + why-is-node-running: "npm:^2.3.0" + peerDependencies: + "@edge-runtime/vm": "*" + "@opentelemetry/api": ^1.9.0 + "@types/node": ^20.0.0 || ^22.0.0 || >=24.0.0 + "@vitest/browser-playwright": 4.1.8 + "@vitest/browser-preview": 4.1.8 + "@vitest/browser-webdriverio": 4.1.8 + "@vitest/coverage-istanbul": 4.1.8 + "@vitest/coverage-v8": 4.1.8 + "@vitest/ui": 4.1.8 + happy-dom: "*" + jsdom: "*" + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + "@edge-runtime/vm": + optional: true + "@opentelemetry/api": + optional: true + "@types/node": + optional: true + "@vitest/browser-playwright": + optional: true + "@vitest/browser-preview": + optional: true + "@vitest/browser-webdriverio": + optional: true + "@vitest/coverage-istanbul": + optional: true + "@vitest/coverage-v8": + optional: true + "@vitest/ui": + optional: true + happy-dom: + optional: true + jsdom: + optional: true + vite: + optional: false + bin: + vitest: vitest.mjs + checksum: 10c0/f459c500f8818c7a2318cd23228b4e5c0b5efb25bf8e5cb7f116c6d26e51482b2f800a8bb19837c0b5f0d05c51519edbf502bc8ceb5bd86868e8facf1d2c498e + languageName: node + linkType: hard + +"vitest@npm:4.1.9": + version: 4.1.9 + resolution: "vitest@npm:4.1.9" + dependencies: + "@vitest/expect": "npm:4.1.9" + "@vitest/mocker": "npm:4.1.9" + "@vitest/pretty-format": "npm:4.1.9" + "@vitest/runner": "npm:4.1.9" + "@vitest/snapshot": "npm:4.1.9" + "@vitest/spy": "npm:4.1.9" + "@vitest/utils": "npm:4.1.9" + es-module-lexer: "npm:^2.0.0" + expect-type: "npm:^1.3.0" + magic-string: "npm:^0.30.21" + obug: "npm:^2.1.1" + pathe: "npm:^2.0.3" + picomatch: "npm:^4.0.3" + std-env: "npm:^4.0.0-rc.1" + tinybench: "npm:^2.9.0" + tinyexec: "npm:^1.0.2" + tinyglobby: "npm:^0.2.15" + tinyrainbow: "npm:^3.1.0" + vite: "npm:^6.0.0 || ^7.0.0 || ^8.0.0" + why-is-node-running: "npm:^2.3.0" + peerDependencies: + "@edge-runtime/vm": "*" + "@opentelemetry/api": ^1.9.0 + "@types/node": ^20.0.0 || ^22.0.0 || >=24.0.0 + "@vitest/browser-playwright": 4.1.9 + "@vitest/browser-preview": 4.1.9 + "@vitest/browser-webdriverio": 4.1.9 + "@vitest/coverage-istanbul": 4.1.9 + "@vitest/coverage-v8": 4.1.9 + "@vitest/ui": 4.1.9 + happy-dom: "*" + jsdom: "*" + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + "@edge-runtime/vm": + optional: true + "@opentelemetry/api": + optional: true + "@types/node": + optional: true + "@vitest/browser-playwright": + optional: true + "@vitest/browser-preview": + optional: true + "@vitest/browser-webdriverio": + optional: true + "@vitest/coverage-istanbul": + optional: true + "@vitest/coverage-v8": + optional: true + "@vitest/ui": + optional: true + happy-dom: + optional: true + jsdom: + optional: true + vite: + optional: false + bin: + vitest: ./vitest.mjs + checksum: 10c0/1ac80ef4991be82822a52aea48415f1bc64ddf8fd88ee24c172ec368f1d480fefacbde622c3c951982f7961a1d07313e18deaafc774d29e42ad6f6ffa63334a7 + languageName: node + linkType: hard + "webdriver-bidi-protocol@npm:0.4.1": version: 0.4.1 resolution: "webdriver-bidi-protocol@npm:0.4.1"