From f3cccc54f5fb66cbde8f80d9fc5a1cb4bd33a607 Mon Sep 17 00:00:00 2001 From: KianKwa Date: Tue, 30 Jun 2026 16:53:41 +0100 Subject: [PATCH 1/6] Add Magistrates Standard List style guide and PDF --- apps/web/package.json | 1 + apps/web/src/app.ts | 2 + .../magistrates-standard-list/index.test.ts | 381 ++++++++++++++ .../magistrates-standard-list/index.ts | 26 + .../magistrates-standard-list.njk | 220 ++++++++ docs/tickets/771/plan.md | 414 +++++++++++++++ docs/tickets/771/tasks.md | 71 +++ docs/tickets/771/ticket.md | 156 ++++++ .../magistrates-standard-list/package.json | 27 + .../magistrates-standard-list/src/config.ts | 8 + .../src/email-summary/summary-builder.test.ts | 184 +++++++ .../src/email-summary/summary-builder.ts | 95 ++++ .../magistrates-standard-list/src/index.ts | 7 + .../src/locales/cy.ts | 52 ++ .../src/locales/en.ts | 53 ++ .../src/models/types.ts | 168 ++++++ .../src/pdf/pdf-generator.ts | 59 +++ .../src/pdf/pdf-template.njk | 173 ++++++ .../src/rendering/renderer.test.ts | 481 +++++++++++++++++ .../src/rendering/renderer.ts | 298 +++++++++++ .../schemas/magistrates-standard-list.json | 492 ++++++++++++++++++ .../src/validation/json-validator.ts | 6 + .../magistrates-standard-list/tsconfig.json | 11 + libs/location/src/list-type-data.ts | 11 + .../src/notification/notification-service.ts | 10 +- libs/publication/src/processing/service.ts | 4 +- tsconfig.json | 4 +- yarn.lock | 7 + 28 files changed, 3418 insertions(+), 3 deletions(-) create mode 100644 apps/web/src/pages/(list-types)/magistrates-standard-list/index.test.ts create mode 100644 apps/web/src/pages/(list-types)/magistrates-standard-list/index.ts create mode 100644 apps/web/src/pages/(list-types)/magistrates-standard-list/magistrates-standard-list.njk create mode 100644 docs/tickets/771/plan.md create mode 100644 docs/tickets/771/tasks.md create mode 100644 docs/tickets/771/ticket.md create mode 100644 libs/list-types/magistrates-standard-list/package.json create mode 100644 libs/list-types/magistrates-standard-list/src/config.ts create mode 100644 libs/list-types/magistrates-standard-list/src/email-summary/summary-builder.test.ts create mode 100644 libs/list-types/magistrates-standard-list/src/email-summary/summary-builder.ts create mode 100644 libs/list-types/magistrates-standard-list/src/index.ts create mode 100644 libs/list-types/magistrates-standard-list/src/locales/cy.ts create mode 100644 libs/list-types/magistrates-standard-list/src/locales/en.ts create mode 100644 libs/list-types/magistrates-standard-list/src/models/types.ts create mode 100644 libs/list-types/magistrates-standard-list/src/pdf/pdf-generator.ts create mode 100644 libs/list-types/magistrates-standard-list/src/pdf/pdf-template.njk create mode 100644 libs/list-types/magistrates-standard-list/src/rendering/renderer.test.ts create mode 100644 libs/list-types/magistrates-standard-list/src/rendering/renderer.ts create mode 100644 libs/list-types/magistrates-standard-list/src/schemas/magistrates-standard-list.json create mode 100644 libs/list-types/magistrates-standard-list/src/validation/json-validator.ts create mode 100644 libs/list-types/magistrates-standard-list/tsconfig.json diff --git a/apps/web/package.json b/apps/web/package.json index e4096caea..01c3c6820 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -34,6 +34,7 @@ "@hmcts/list-types-common": "workspace:*", "@hmcts/location": "workspace:*", "@hmcts/london-administrative-court-daily-cause-list": "workspace:*", + "@hmcts/magistrates-standard-list": "workspace:*", "@hmcts/public-pages": "workspace:*", "@hmcts/rcj-standard-daily-cause-list": "workspace:*", "@hmcts/sjp-press-list": "workspace:*", diff --git a/apps/web/src/app.ts b/apps/web/src/app.ts index 1e092c9df..49a81dc9a 100644 --- a/apps/web/src/app.ts +++ b/apps/web/src/app.ts @@ -13,6 +13,7 @@ import { moduleRoot as familyDailyCauseListModuleRoot } from "@hmcts/family-dail import { moduleRoot as listTypesCommonModuleRoot } from "@hmcts/list-types-common/config"; import { apiRoutes as locationApiRoutes } from "@hmcts/location/config"; import { moduleRoot as londonAdminModuleRoot } from "@hmcts/london-administrative-court-daily-cause-list/config"; +import { moduleRoot as magistratesStandardListModuleRoot } from "@hmcts/magistrates-standard-list/config"; import { apiRoutes as publicPagesApiRoutes, fileUploadRoutes as publicPagesFileUploadRoutes, @@ -101,6 +102,7 @@ export async function createApp(): Promise { londonAdminModuleRoot, civilAppealModuleRoot, adminCourtModuleRoot, + magistratesStandardListModuleRoot, systemAdminModuleRoot, publicPagesModuleRoot ]; diff --git a/apps/web/src/pages/(list-types)/magistrates-standard-list/index.test.ts b/apps/web/src/pages/(list-types)/magistrates-standard-list/index.test.ts new file mode 100644 index 000000000..9553349d2 --- /dev/null +++ b/apps/web/src/pages/(list-types)/magistrates-standard-list/index.test.ts @@ -0,0 +1,381 @@ +import { renderMagistratesStandardListData, validateMagistratesStandardList } from "@hmcts/magistrates-standard-list"; +import { prisma } from "@hmcts/postgres-prisma"; +import { canAccessPublicationData, getArtefactById, getPublicationJson } from "@hmcts/publication"; +import type { Request, Response } from "express"; +import { afterAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { GET } from "./index.js"; + +vi.mock("@hmcts/postgres-prisma", () => ({ + prisma: { + artefact: { + findUnique: vi.fn() + }, + listType: { + findUnique: vi.fn() + } + } +})); +vi.mock("@hmcts/publication", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getArtefactById: vi.fn(), + getPublicationJson: vi.fn(), + canAccessPublicationData: vi.fn() + }; +}); +vi.mock("@hmcts/magistrates-standard-list"); + +describe("magistrates-standard-list controller", () => { + let req: Partial; + let res: Partial; + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + const mockListType = { + id: 28, + allowedProvenance: ["MANUAL_UPLOAD"], + isNonStrategic: false + }; + + beforeEach(() => { + vi.clearAllMocks(); + req = { + query: {} + }; + res = { + locals: { locale: "en" }, + status: vi.fn().mockReturnThis(), + render: vi.fn() + }; + vi.mocked(prisma.listType.findUnique).mockResolvedValue(mockListType as any); + vi.mocked(canAccessPublicationData).mockReturnValue(true); + }); + + afterAll(() => { + consoleErrorSpy.mockRestore(); + }); + + describe("GET", () => { + it("should return 400 when artefactId is missing", async () => { + req.query = {}; + + await GET(req as Request, res as Response); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.render).toHaveBeenCalledWith( + "errors/common", + expect.objectContaining({ + errorTitle: expect.any(String), + errorMessage: expect.any(String) + }) + ); + }); + + it("should return 404 when artefact is not found", async () => { + req.query = { artefactId: "nonexistent-id" }; + vi.mocked(getArtefactById).mockResolvedValue(null); + + await GET(req as Request, res as Response); + + expect(getArtefactById).toHaveBeenCalledWith("nonexistent-id"); + expect(res.status).toHaveBeenCalledWith(404); + expect(res.render).toHaveBeenCalledWith("errors/common", expect.any(Object)); + }); + + it("should return 403 when access is denied", async () => { + req.query = { artefactId: "test-id" }; + const mockArtefact = { + artefactId: "test-id", + locationId: "1", + listTypeId: 28, + contentDate: new Date("2025-01-13"), + sensitivity: "PUBLIC", + language: "ENGLISH", + displayFrom: new Date("2025-01-13"), + displayTo: new Date("2025-01-20"), + lastReceivedDate: new Date("2025-01-13"), + isFlatFile: false, + provenance: "MANUAL_UPLOAD", + supersededCount: 0, + noMatch: false + } as any; + vi.mocked(getArtefactById).mockResolvedValue(mockArtefact); + vi.mocked(canAccessPublicationData).mockReturnValue(false); + + await GET(req as Request, res as Response); + + expect(res.status).toHaveBeenCalledWith(403); + expect(res.render).toHaveBeenCalledWith("errors/403", expect.any(Object)); + }); + + it("should return 404 when JSON blob is not found", async () => { + req.query = { artefactId: "test-id" }; + const mockArtefact = { + artefactId: "test-id", + locationId: "1", + listTypeId: 28, + contentDate: new Date("2025-01-13"), + sensitivity: "PUBLIC", + language: "ENGLISH", + displayFrom: new Date("2025-01-13"), + displayTo: new Date("2025-01-20"), + lastReceivedDate: new Date("2025-01-13"), + isFlatFile: false, + provenance: "MANUAL_UPLOAD", + supersededCount: 0, + noMatch: false + } as any; + vi.mocked(getArtefactById).mockResolvedValue(mockArtefact); + vi.mocked(getPublicationJson).mockResolvedValue(null); + + await GET(req as Request, res as Response); + + expect(consoleErrorSpy).toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(404); + expect(res.render).toHaveBeenCalledWith("errors/common", expect.any(Object)); + }); + + it("should return 400 when JSON validation fails", async () => { + req.query = { artefactId: "test-id" }; + const mockArtefact = { + artefactId: "test-id", + locationId: "1", + listTypeId: 28, + contentDate: new Date("2025-01-13"), + sensitivity: "PUBLIC", + language: "ENGLISH", + displayFrom: new Date("2025-01-13"), + displayTo: new Date("2025-01-20"), + lastReceivedDate: new Date("2025-01-13"), + isFlatFile: false, + provenance: "MANUAL_UPLOAD", + supersededCount: 0, + noMatch: false + } as any; + vi.mocked(getArtefactById).mockResolvedValue(mockArtefact); + vi.mocked(getPublicationJson).mockResolvedValue({ invalid: "data" }); + vi.mocked(validateMagistratesStandardList).mockReturnValue({ + isValid: false, + errors: ["Validation error"], + schemaVersion: "1.0" + } as any); + + await GET(req as Request, res as Response); + + expect(validateMagistratesStandardList).toHaveBeenCalled(); + expect(consoleErrorSpy).toHaveBeenCalledWith("[magistrates-standard-list] Validation errors:", ["Validation error"]); + expect(res.status).toHaveBeenCalledWith(400); + expect(res.render).toHaveBeenCalledWith("errors/common", expect.any(Object)); + }); + + it("should successfully render with valid data in English", async () => { + req.query = { artefactId: "test-id" }; + res.locals = { locale: "en" }; + const mockArtefact = { + artefactId: "test-id", + locationId: "1", + listTypeId: 28, + contentDate: new Date("2025-01-13"), + sensitivity: "PUBLIC", + language: "ENGLISH", + displayFrom: new Date("2025-01-13"), + displayTo: new Date("2025-01-20"), + lastReceivedDate: new Date("2025-01-13"), + isFlatFile: false, + provenance: "MANUAL_UPLOAD", + supersededCount: 0, + noMatch: false + } as any; + const mockJsonData = { + document: { publicationDate: "2025-01-13T09:30:00.000Z" }, + venue: { venueAddress: { line: ["Court Address"] } }, + courtLists: [] + }; + const mockRenderedData = { + header: { + contentDate: "13 January 2025", + publishedDate: "13 January 2025", + publishedTime: "09:30", + venueAddress: "Court Address" + }, + listData: [] + }; + vi.mocked(getArtefactById).mockResolvedValue(mockArtefact); + vi.mocked(getPublicationJson).mockResolvedValue(mockJsonData); + vi.mocked(validateMagistratesStandardList).mockReturnValue({ + isValid: true, + errors: [], + schemaVersion: "1.0" + } as any); + vi.mocked(renderMagistratesStandardListData).mockResolvedValue(mockRenderedData as any); + + await GET(req as Request, res as Response); + + expect(renderMagistratesStandardListData).toHaveBeenCalledWith(mockJsonData, { + locale: "en", + locationId: "1", + contentDate: mockArtefact.contentDate + }); + const renderCall = vi.mocked(res.render!).mock.calls[0]!; + expect(renderCall[0]).toBe("magistrates-standard-list"); + expect(renderCall[1]).toMatchObject({ + dataSource: "Manual Upload" + }); + expect(renderCall[1]).toHaveProperty("en"); + expect(renderCall[1]).toHaveProperty("cy"); + expect(renderCall[1]).toHaveProperty("header"); + expect(renderCall[1]).toHaveProperty("listData"); + expect(renderCall[1]).toHaveProperty("t"); + }); + + it("should successfully render with Welsh locale", async () => { + req.query = { artefactId: "test-id" }; + res.locals = { locale: "cy" }; + const mockArtefact = { + artefactId: "test-id", + locationId: "1", + listTypeId: 28, + contentDate: new Date("2025-01-13"), + sensitivity: "PUBLIC", + language: "WELSH", + displayFrom: new Date("2025-01-13"), + displayTo: new Date("2025-01-20"), + lastReceivedDate: new Date("2025-01-13"), + isFlatFile: false, + provenance: "MANUAL_UPLOAD", + supersededCount: 0, + noMatch: false + } as any; + const mockJsonData = { + document: { publicationDate: "2025-01-13T09:30:00.000Z" }, + venue: { venueAddress: { line: ["Cyfeiriad y Llys"] } }, + courtLists: [] + }; + const mockRenderedData = { + header: { + contentDate: "13 Ionawr 2025", + publishedDate: "13 Ionawr 2025", + publishedTime: "09:30", + venueAddress: "Cyfeiriad y Llys" + }, + listData: [] + }; + vi.mocked(getArtefactById).mockResolvedValue(mockArtefact); + vi.mocked(getPublicationJson).mockResolvedValue(mockJsonData); + vi.mocked(validateMagistratesStandardList).mockReturnValue({ + isValid: true, + errors: [], + schemaVersion: "1.0" + } as any); + vi.mocked(renderMagistratesStandardListData).mockResolvedValue(mockRenderedData as any); + + await GET(req as Request, res as Response); + + expect(renderMagistratesStandardListData).toHaveBeenCalledWith(mockJsonData, { + locale: "cy", + locationId: "1", + contentDate: mockArtefact.contentDate + }); + expect(res.render).toHaveBeenCalledWith("magistrates-standard-list", expect.any(Object)); + }); + + it("should return 500 on unexpected error", async () => { + req.query = { artefactId: "test-id" }; + vi.mocked(getArtefactById).mockRejectedValue(new Error("Database error")); + + await GET(req as Request, res as Response); + + expect(consoleErrorSpy).toHaveBeenCalledWith("[magistrates-standard-list] Unexpected error:", expect.any(Error)); + expect(res.status).toHaveBeenCalledWith(500); + expect(res.render).toHaveBeenCalledWith("errors/common", expect.any(Object)); + }); + + it("should use provenance label for data source", async () => { + req.query = { artefactId: "test-id" }; + const mockArtefact = { + artefactId: "test-id", + locationId: "1", + listTypeId: 28, + contentDate: new Date("2025-01-13"), + sensitivity: "PUBLIC", + language: "ENGLISH", + displayFrom: new Date("2025-01-13"), + displayTo: new Date("2025-01-20"), + lastReceivedDate: new Date("2025-01-13"), + isFlatFile: false, + provenance: "MANUAL_UPLOAD", + supersededCount: 0, + noMatch: false + } as any; + const mockJsonData = { + document: { publicationDate: "2025-01-13T09:30:00.000Z" }, + venue: { venueAddress: { line: ["Court Address"] } }, + courtLists: [] + }; + vi.mocked(getArtefactById).mockResolvedValue(mockArtefact); + vi.mocked(getPublicationJson).mockResolvedValue(mockJsonData); + vi.mocked(validateMagistratesStandardList).mockReturnValue({ + isValid: true, + errors: [], + schemaVersion: "1.0" + } as any); + vi.mocked(renderMagistratesStandardListData).mockReturnValue({ + header: { contentDate: "13 January 2025", publishedDate: "13 January 2025", publishedTime: "09:30", venueAddress: "" }, + listData: [] + } as any); + + await GET(req as Request, res as Response); + + expect(res.render).toHaveBeenCalledWith( + "magistrates-standard-list", + expect.objectContaining({ + dataSource: "Manual Upload" + }) + ); + }); + + it("should use raw provenance when label not found", async () => { + req.query = { artefactId: "test-id" }; + const mockArtefact = { + artefactId: "test-id", + locationId: "1", + listTypeId: 28, + contentDate: new Date("2025-01-13"), + sensitivity: "PUBLIC", + language: "ENGLISH", + displayFrom: new Date("2025-01-13"), + displayTo: new Date("2025-01-20"), + lastReceivedDate: new Date("2025-01-13"), + isFlatFile: false, + provenance: "UNKNOWN_PROVENANCE", + supersededCount: 0, + noMatch: false + } as any; + const mockJsonData = { + document: { publicationDate: "2025-01-13T09:30:00.000Z" }, + venue: {}, + courtLists: [] + }; + vi.mocked(getArtefactById).mockResolvedValue(mockArtefact); + vi.mocked(getPublicationJson).mockResolvedValue(mockJsonData); + vi.mocked(validateMagistratesStandardList).mockReturnValue({ + isValid: true, + errors: [], + schemaVersion: "1.0" + } as any); + vi.mocked(renderMagistratesStandardListData).mockReturnValue({ + header: { contentDate: "13 January 2025", publishedDate: "13 January 2025", publishedTime: "09:30", venueAddress: "" }, + listData: [] + } as any); + + await GET(req as Request, res as Response); + + expect(res.render).toHaveBeenCalledWith( + "magistrates-standard-list", + expect.objectContaining({ + dataSource: "UNKNOWN_PROVENANCE" + }) + ); + }); + }); +}); diff --git a/apps/web/src/pages/(list-types)/magistrates-standard-list/index.ts b/apps/web/src/pages/(list-types)/magistrates-standard-list/index.ts new file mode 100644 index 000000000..00181d163 --- /dev/null +++ b/apps/web/src/pages/(list-types)/magistrates-standard-list/index.ts @@ -0,0 +1,26 @@ +import { + magistratesStandardListCy as cy, + magistratesStandardListEn as en, + type MagistratesStandardList, + renderMagistratesStandardListData, + validateMagistratesStandardList +} from "@hmcts/magistrates-standard-list"; +import { createListTypeHandler, resolveDataSource } from "../list-type-handler.js"; + +export const GET = createListTypeHandler({ + en, + cy, + validate: validateMagistratesStandardList, + logPrefix: "magistrates-standard-list", + checkAccess: true, + render: async ({ artefact, jsonData, locale, res }) => { + const t = locale === "cy" ? cy : en; + const { header, listData } = await renderMagistratesStandardListData(jsonData, { + locale, + locationId: artefact.locationId, + contentDate: artefact.contentDate + }); + const dataSource = resolveDataSource(artefact.provenance); + res.render("magistrates-standard-list", { en, cy, t, title: t.title, header, listData, dataSource }); + } +}); diff --git a/apps/web/src/pages/(list-types)/magistrates-standard-list/magistrates-standard-list.njk b/apps/web/src/pages/(list-types)/magistrates-standard-list/magistrates-standard-list.njk new file mode 100644 index 000000000..7da1e9eba --- /dev/null +++ b/apps/web/src/pages/(list-types)/magistrates-standard-list/magistrates-standard-list.njk @@ -0,0 +1,220 @@ +{% extends "layouts/base-template.njk" %} + +{% block head %} + {{ super() }} + +{% endblock %} + +{% block page_content %} +
+
+ +

{{ t.pageTitle }} {{ header.locationName }}

+ +

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

+ +

{{ t.listDate }} {{ header.contentDate }}

+

{{ t.lastUpdated }} {{ header.publishedDate }} {{ t.publishedAt }} {{ header.publishedTime }}

+ {% if header.venueAddress.length > 0 %} +

{% for line in header.venueAddress %}{{ line }}{% if not loop.last %}
{% endif %}{% endfor %}

+ {% endif %} + +
+
+

{{ t.restrictionInformationHeading }}

+

{{ t.restrictionInformationP1 }}

+
+ + + Warning + {{ t.restrictionInformationBoldText }} + +
+

{{ t.restrictionInformationP2 }}

+

{{ t.restrictionInformationP3 }}

+

{{ t.restrictionInformationP4 }}

+
    +
  • {{ t.restrictionBulletPoint1 }}
  • +
  • {{ t.restrictionBulletPoint2 }}
  • +
+
+
+ +
+

{{ t.searchCases }}

+ +
+ + {% if listData.length === 0 %} +

{{ t.noHearings }}

+ {% endif %} + +
+ {% set courtRoomCount = 0 %} + {% for room in listData %} + {% set courtRoomCount = courtRoomCount + 1 %} + + +
+ {% set sittingCount = 0 %} + {% for sitting in room.sittings %} + {% set sittingCount = sittingCount + 1 %} +
+
+

+ + {{ t.sittingAt }}{{ sitting.sittingHeading }} + +

+
+
+ {% set hearingCount = 0 %} + {% for hearing in sitting.hearings %} + {% set hearingCount = hearingCount + 1 %} + {% set borderClass = '' %} + {% if hearingCount < sitting.hearings | length %} + {% set borderClass = 'add-border-bottom govuk-!-margin-bottom-5' %} + {% endif %} +
+
+
+

{{ t.name }}{{ hearing.partyInfo.name }}

+ {% if hearing.applicationParticulars %} +

{{ t.applicationParticulars }}{{ hearing.applicationParticulars }}

+ {% endif %} + {% if hearing.partyInfo.dob or hearing.partyInfo.age %} +

+ {{ t.dobAndAge }} + {% if hearing.partyInfo.dob %}{{ hearing.partyInfo.dob }}{% endif %} + {% if hearing.partyInfo.age %} {{ t.age }} {{ hearing.partyInfo.age }}{% endif %} +

+ {% endif %} +

{{ t.address }}{{ hearing.partyInfo.address }}

+ {% if hearing.prosecutingAuthority %} +

{{ t.prosecutingAuthority }}{{ hearing.prosecutingAuthority }}

+ {% endif %} + {% if hearing.attendanceMethod %} +

{{ t.attendanceMethod }}{{ hearing.attendanceMethod }}

+ {% endif %} + {% if hearing.reportingRestriction %} +

{{ t.reportingRestrictions }}{{ hearing.reportingRestrictionDetails }}

+ {% endif %} +
+
+

{{ t.reference }}{{ hearing.reference }}

+ {% if hearing.applicationType %} +

{{ t.applicationType }}{{ hearing.applicationType }}

+ {% endif %} +

{{ t.asn }}{{ hearing.partyInfo.asn }}

+

{{ t.pncId }}{{ hearing.partyInfo.pncId }}

+ {% if hearing.hearingType %} +

{{ t.hearingType }}{{ hearing.hearingType }}

+ {% endif %} + {% if hearing.panel %} +

{{ t.panel }}{{ hearing.panel }}

+ {% endif %} +
+
+ + {% set offenceCount = 0 %} + {% for offence in hearing.offences %} + {% if offence.offenceTitle %} + {% set offenceCount = offenceCount + 1 %} +
+
+ + + {{ offenceCount }}. + {% if offence.offenceCode %}{{ offence.offenceCode }}{% endif %} + {% if offence.offenceCode and offence.offenceTitle %} - {% endif %} + {{ offence.offenceTitle }} + + +
+ + + {% if offence.offenceWording %} + + + + {% endif %} + {% if offence.offenceLegislation %} + + + + + {% endif %} + {% if offence.offenceMaxPenalty %} + + + + + {% endif %} + {% if offence.plea %} + + + + + {% endif %} + {% if offence.pleaDate %} + + + + + {% endif %} + {% if offence.convictionDate %} + + + + + {% endif %} + {% if offence.adjournedDate %} + + + + + {% endif %} + {% if offence.reportingRestriction %} + + + + + {% endif %} + +
{{ offence.offenceWording }}
{{ t.legislation }}{{ offence.offenceLegislation }}
{{ t.maxPenalty }}{{ offence.offenceMaxPenalty }}
{{ t.plea }}{{ offence.plea }}
{{ t.dateOfPlea }}{{ offence.pleaDate }}
{{ t.convictedOn }}{{ offence.convictionDate }}
{{ t.adjournedFrom }}{{ offence.adjournedDate }} - {{ t.adjournedText }}
{{ t.reportingRestrictions }}{{ offence.reportingRestrictionDetails }}
+
+
+
+ {% endif %} + {% endfor %} +
+ {% endfor %} +
+
+ {% endfor %} +
+ {% endfor %} + +

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

+
+ +
+
+{% endblock %} diff --git a/docs/tickets/771/plan.md b/docs/tickets/771/plan.md new file mode 100644 index 000000000..2d691c8f8 --- /dev/null +++ b/docs/tickets/771/plan.md @@ -0,0 +1,414 @@ +# #771: Magistrates Standard List - Technical Plan + +## 1. Technical Approach + +The magistrates-standard-list module follows the `civil-and-family-daily-cause-list` reference implementation with one key structural difference: magistrates lists always include a statutory reporting restriction section in both HTML and PDF output. This section is not conditional — it is rendered on every page and in every PDF. + +The architecture splits cleanly: + +- **`libs/list-types/magistrates-standard-list/`** — the library containing all business logic: renderer, validator, types, locales, PDF generator, JSON schema, config, and exports +- **`apps/web/src/pages/(list-types)/magistrates-standard-list/`** — the page controller, Nunjucks template, and co-located en/cy content files + +The page controller delegates everything to `createListTypeHandler` from `list-type-handler.ts`, which handles the full request lifecycle (missing artefactId, artefact not found, access control, JSON retrieval, schema validation, and error rendering). This is identical to the `civil-and-family-daily-cause-list` controller pattern. + +### Dependency on common libraries + +- `@hmcts/list-types-common` — `configureNunjucks`, `savePdfToStorage`, `createPdfErrorResult`, `loadTranslations`, PDF style constants +- `@hmcts/pdf-generation` — `generatePdfFromHtml` +- `@hmcts/publication` — `validateJson`, `PROVENANCE_LABELS`, `ValidationResult` +- `@hmcts/daily-cause-list-common` — NOT reused. The magistrates-standard-list has a different JSON structure (defendant-centric, not court-room/session/sitting hierarchy) and requires its own renderer +- `@hmcts/location` — `getLocationById` for resolving the Welsh court name + +--- + +## 2. Implementation Details + +### 2.1 Exact file structure + +``` +libs/list-types/magistrates-standard-list/ +├── package.json +├── tsconfig.json +└── src/ + ├── config.ts # moduleRoot, assets, schemaPath exports + ├── config.test.ts # Verifies config exports + ├── index.ts # Business logic re-exports + ├── index.test.ts # Verifies all expected exports present + ├── models/ + │ └── types.ts # TypeScript types for the magistrates-standard-list JSON + ├── schemas/ + │ └── magistrates-standard-list.json # JSON Schema for validation + ├── validation/ + │ └── json-validator.ts # validateMagistratesStandardList() + │ └── json-validator.test.ts + ├── rendering/ + │ └── renderer.ts # renderMagistratesStandardListData() + │ └── renderer.test.ts + ├── locales/ + │ └── en.ts # Shared translations (errorTitle, errorMessage, etc.) + │ └── cy.ts + ├── pdf/ + │ └── pdf-generator.ts # generateMagistratesStandardListPdf() + │ └── pdf-generator.test.ts + │ └── pdf-template.njk # Self-contained HTML PDF template + +apps/web/src/pages/(list-types)/magistrates-standard-list/ +├── index.ts # GET handler using createListTypeHandler +├── index.test.ts # Controller tests +├── en.ts # Page-specific English content +├── cy.ts # Page-specific Welsh content +└── magistrates-standard-list.njk # Nunjucks template for HTML view +``` + +### 2.2 Reporting restriction handling in `civil-and-family-daily-cause-list` and how to replicate + +In `civil-and-family-daily-cause-list`, reporting restrictions are **per-case inline annotations**. In the HTML template, after each table row, there is a conditional extra row: + +```njk +{% if case.formattedReportingRestriction | length %} + + + {{ t.reportingRestrictions }}: {{ case.formattedReportingRestriction }} + + +{% endif %} +``` + +The renderer in `daily-cause-list-common` populates this via `formatReportingRestrictions(caseItem)` which joins the `reportingRestrictionDetail` array. + +For **magistrates-standard-list**, the reporting restriction handling is different. Based on the ticket's acceptance criteria, there is: + +1. A **static statutory notice section** — always displayed, listing the three legal provisions (Children Act s49, s39, and Criminal Procedure (Insanity) Act s4A). This is pure content from locale strings, rendered unconditionally in both HTML and PDF. +2. Potentially **per-case reporting restriction flags** from the JSON data, which should also be rendered in the hearings table rows (following the same pattern as civil-and-family if present in the schema). + +The static restriction section is rendered at the top of the page, after the header/venue block and before the hearings table. In the PDF, it appears in the same logical position. + +Template structure for the static section (HTML): + +```njk +
+

{{ t.restrictionInformationHeading }}

+

{{ t.restrictionInformationP1 }}

+

{{ t.restrictionInformationBoldText }}

+

{{ t.restrictionInformationP2 }}

+
    +
  • {{ t.restrictionBulletPoint1 }}
  • +
  • {{ t.restrictionBulletPoint2 }}
  • +
+

{{ t.restrictionInformationP3 }}

+
    +
  • {{ t.restrictionBulletPoint1 }}
  • +
  • {{ t.restrictionBulletPoint2 }}
  • +
+

{{ t.restrictionInformationP4 }}

+
+``` + +### 2.3 TypeScript types for the magistrates-standard-list JSON structure + +The magistrates-standard-list JSON schema (from pip-data-management) follows a flat defendant-centric structure within the court list hierarchy. Based on the ticket columns (defendant name, case number, offence, plea, results, results proviso) and the known pip-data-management schema, the types are: + +```typescript +// libs/list-types/magistrates-standard-list/src/models/types.ts + +export interface MagistratesDefendant { + name?: string; + caseNumber?: string; + offence?: string[]; + plea?: string; + results?: string; + resultsProviso?: string; + reportingRestrictionDetail?: string[]; + individualDetails?: { + title?: string; + individualForenames?: string; + individualMiddleName?: string; + individualSurname?: string; + }; +} + +export interface MagistratesHearing { + hearingTime?: string; + defendant?: MagistratesDefendant[]; +} + +export interface MagistratesSession { + sittings?: MagistratesSitting[]; + judiciary?: Array<{ johKnownAs: string; isPresiding?: boolean }>; +} + +export interface MagistratesSitting { + sittingStart?: string; + hearing?: MagistratesHearing[]; +} + +export interface MagistratesCourtRoom { + courtRoomName?: string; + session?: MagistratesSession[]; +} + +export interface MagistratesCourtHouse { + courtHouseName?: string; + courtHouseAddress?: { + line?: string[]; + town?: string; + county?: string; + postCode?: string; + }; + courtRoom?: MagistratesCourtRoom[]; +} + +export interface MagistratesCourtList { + courtHouse: MagistratesCourtHouse; +} + +export interface MagistratesStandardListData { + document: { + publicationDate: string; + documentName?: string; + version?: string; + }; + venue: { + venueName: string; + venueAddress: { + line: string[]; + town?: string; + county?: string; + postCode: string; + }; + venueContact?: { + venueTelephone?: string; + venueEmail?: string; + }; + }; + courtLists: MagistratesCourtList[]; +} + +export interface RenderOptions { + locationId: string; + contentDate: Date; + locale: string; +} +``` + +Note: The actual schema structure needs to be verified against the pip-data-management source (`magistrates_standard_list.json`). There is ambiguity about whether defendants appear directly under sittings or within a hearing wrapper — the schema should be consulted to confirm. See Open Questions. + +### 2.4 Renderer transformation + +The renderer in `libs/list-types/magistrates-standard-list/src/rendering/renderer.ts` is responsible for: + +1. Looking up the location name from the DB (`getLocationById`) and preferring Welsh if locale is `cy` +2. Formatting the content date and last updated timestamp +3. Flattening nested defendant/case data into a structure suitable for template iteration +4. Formatting per-case reporting restrictions (joining the `reportingRestrictionDetail` array) for any inline annotations in the table + +The renderer returns: + +```typescript +{ + header: { + locationName: string; + addressLines: string[]; + contentDate: string; // formatted, locale-aware + lastUpdated: string; // formatted, locale-aware + }; + listData: MagistratesStandardListData; // mutated with computed fields +} +``` + +Unlike the civil-and-family renderer, there is no `openJustice` object because the magistrates-standard-list uses a reporting restriction section instead. + +### 2.5 Template structure (HTML) + +`apps/web/src/pages/(list-types)/magistrates-standard-list/magistrates-standard-list.njk`: + +``` +extends layouts/base-template.njk +block page_content + - H1: venue name + list date (using t.title) + - Venue address lines + - List date line + - Last updated line + - Reporting restriction section (static, unconditional, using t.restrictionInformationHeading etc.) + - Open Justice collapsible (govuk-details, using t.openJusticeTitle) + - Case search input + - govuk-accordion with court rooms and sessions + - Table per session: Time | Defendant name | Case number | Offence | Plea | Results + - Inline reporting restriction row per case if present + - "No hearings today" message if courtLists is empty + - Back to top link + - Data source attribution +``` + +### 2.6 Template structure (PDF) + +`libs/list-types/magistrates-standard-list/src/pdf/pdf-template.njk`: + +Self-contained HTML using inline CSS (from `pdfStyles`). Same logical layout as the HTML view: + +``` +- Header: venue name, address, list date, last updated +- Reporting restriction section (static) +- Table per court room/session: defendant data +- Footer: data source +``` + +The PDF generator uses `configureNunjucks(__dirname)` pointing to the `pdf/` directory, and `PDF_BASE_STYLES` from `@hmcts/list-types-common`. + +### 2.7 Locale files + +The library provides shared locales for error messages and page metadata. Page-specific content (heading text, column labels, reporting restriction paragraphs) lives co-located with the page controller in `apps/web/src/pages/(list-types)/magistrates-standard-list/en.ts` and `cy.ts`. + +**Lib locales** (`libs/list-types/magistrates-standard-list/src/locales/en.ts`): + +```typescript +export const en = { + errorTitle: "Publication not available", + errorMessage: "This publication cannot be viewed at the moment. ...", + error403Title: "Access Denied", + error403Message: "You do not have permission to view this publication." +}; +``` + +**Page locales** (`apps/web/src/pages/(list-types)/magistrates-standard-list/en.ts`): + +All content keys from the ticket's acceptance criteria table, plus the reporting restriction keys, column headers, and common error texts. The page locale objects are passed as `en` and `cy` to the template. + +### 2.8 Registration + +**`apps/web/src/app.ts`**: + +```typescript +import { moduleRoot as magistratesStandardListModuleRoot } from "@hmcts/magistrates-standard-list/config"; + +// Add to modulePaths array: +magistratesStandardListModuleRoot, +``` + +**`apps/web/package.json`** — add dependency: + +```json +"@hmcts/magistrates-standard-list": "workspace:*", +``` + +**Root `tsconfig.json`** — add path alias: + +```json +"@hmcts/magistrates-standard-list": ["libs/list-types/magistrates-standard-list/src"], +``` + +**`libs/publication/src/processing/service.ts`** — add PDF generator to registry: + +```typescript +import { generateMagistratesStandardListPdf, type MagistratesStandardListData } from "@hmcts/magistrates-standard-list"; + +MAGISTRATES_STANDARD_LIST: (p) => generateMagistratesStandardListPdf({ ...p, jsonData: p.jsonData as MagistratesStandardListData }), +``` + +**`libs/location/src/list-type-data.ts`** — add list type entry with the correct ID (the next available id after 27): + +```typescript +{ + id: 28, // Verify this is correct against the DB seed + name: "MAGISTRATES_STANDARD_LIST", + englishFriendlyName: "Magistrates Standard List", + welshFriendlyName: "Rhestr Safonol Llys Ynadon", + provenance: "CRIME_IDAM", + urlPath: "magistrates-standard-list", + isNonStrategic: false, + defaultSensitivity: "Public", + subJurisdictionIds: [7] +} +``` + +**`libs/publication/package.json`** — add dependency on `@hmcts/magistrates-standard-list`. + +**`libs/notifications/package.json`** — if email summary is added later. + +### 2.9 `package.json` for the lib + +The lib needs the same build scripts as `civil-and-family-daily-cause-list`: + +```json +"build": "tsc && yarn build:pdf-templates", +"build:pdf-templates": "mkdir -p dist/pdf && cp src/pdf/*.njk dist/pdf/", +``` + +No `build:nunjucks` script is needed because the HTML template lives in `apps/web/src/pages/`, not in the lib. + +--- + +## 3. Error Handling and Edge Cases + +- **Missing `artefactId`**: `createListTypeHandler` returns 400 before any DB lookup +- **Artefact not found**: 404 via `getArtefactById` returning null +- **Access denied**: 403 if `checkAccess: true` and `canAccessPublicationData` returns false. The access control behaviour for magistrates-standard-list (whether `checkAccess` should be true or false) needs confirming — `civil-and-family-daily-cause-list` uses `checkAccess: true`. Magistrates lists use `CRIME_IDAM` provenance, so the same access control logic should apply. +- **JSON blob not found**: 404 logged with artefact ID +- **JSON schema validation failure**: 400 with validation errors logged +- **Empty court list**: Template renders `t.noHearings` message instead of an empty accordion +- **Missing defendant name**: Graceful empty string, not a crash +- **Locale fallback**: Default to `"en"` if `res.locals.locale` is not set + +--- + +## 4. Acceptance Criteria Mapping + +| Criterion | Implementation | +|-----------|---------------| +| New lib at `libs/list-types/magistrates-standard-list/` | Lib scaffolded with all files listed in section 2.1 | +| Page at `GET /magistrates-standard-list?artefactId=` | Page controller at `apps/web/src/pages/(list-types)/magistrates-standard-list/index.ts` | +| Displays venue name, address, content date, last updated | `header` object from renderer, passed to template | +| Hearings table with correct columns | Template renders defendant-centric table using court list data | +| Reporting restriction section | Static section in HTML template using locale keys; same in PDF | +| Open Justice collapsible | `govuk-details` component with `t.openJusticeTitle` | +| Case search input | HTML input element with `id="case-search-input"` | +| Data source attribution | `dataSource` variable resolved via `PROVENANCE_LABELS` | +| Returns 400 if `artefactId` missing | `createListTypeHandler` handles this before any DB call | +| Returns 404 if artefact not found | `createListTypeHandler` handles this | +| Returns 403 if no access | `createListTypeHandler` with `checkAccess: true` | +| Returns 400 if JSON fails validation | `createListTypeHandler` validates against schema | +| PDF generated matching HTML structure | `pdf-template.njk` mirrors HTML layout | +| PDF includes reporting restriction | Static section in `pdf-template.njk` | +| PDF saved to storage | `savePdfToStorage` via `@hmcts/list-types-common` | +| Welsh via `?lng=cy` | `cy.ts` locale files, locale resolved from `res.locals.locale` | +| Module registered in `apps/web/src/app.ts` | `moduleRoot` import added to `modulePaths` | +| Path alias in `tsconfig.json` | Entry added to root `tsconfig.json` paths | +| Package added to `apps/web/package.json` | Workspace dependency added | +| Unit tests pass | Tests for renderer, validator, PDF generator, page controller, config | +| `yarn test` passes | All test suites green | + +--- + +## 5. Open Questions (CLARIFICATIONS NEEDED) + +### 5.1 Magistrates-standard-list JSON schema structure + +The exact field names and nesting depth for defendants within the JSON need to be confirmed against the pip-data-management source file (`src/main/resources/schemas/magistrates_standard_list.json`). Key questions: + +- Are defendants nested directly under `sitting`, or within a `hearing` wrapper under `sitting`? +- What is the exact field name for the defendant's name? (`name`, `individualDetails`, or a combined string?) +- Is `offence` a single string or an array? +- Is `hearingTime` a field on the sitting or on the defendant/case? +- Are there any required fields vs optional fields for the table columns (offence, plea, results, resultsProviso)? + +Without the source schema, the types in section 2.3 are best-effort approximations based on the ticket's column definitions. + +### 5.2 List type ID for MAGISTRATES_STANDARD_LIST + +The `list-type-data.ts` currently has IDs up to 27. The correct next ID (28) needs to be confirmed against the actual database state and any pending migrations. If there is already a DB record for this list type, the ID must match exactly. + +### 5.3 `checkAccess` flag for the page controller + +The ticket says to follow `civil-and-family-daily-cause-list` which uses `checkAccess: true`. Magistrates lists use `CRIME_IDAM` provenance (same as magistrates-public-list, id 4). Confirm whether access control should be applied the same way for the standard list. + +### 5.4 Open Justice section content + +The ticket specifies `openJusticeTitle` as a locale key but does not provide the full Open Justice paragraph text for magistrates-standard-list. Should it reuse the same Open Justice paragraphs as `civil-and-family-daily-cause-list`, or is there magistrates-specific text? This affects whether `venueContact` data is needed from the JSON. + +### 5.5 Reporting restriction section bullet point structure + +The ticket shows the same two bullet points listed under both s49 (P2) and s39 (P3). Confirm this is the intended rendering — two bullet point lists with identical items under two different statutory provisions — before implementing the template. + +### 5.6 `libs/publication` dependency + +Adding `MAGISTRATES_STANDARD_LIST` to the `PDF_GENERATOR_REGISTRY` in `libs/publication/src/processing/service.ts` requires adding `@hmcts/magistrates-standard-list` as a dependency in `libs/publication/package.json`. Confirm this is acceptable and will not create a circular dependency. diff --git a/docs/tickets/771/tasks.md b/docs/tickets/771/tasks.md new file mode 100644 index 000000000..095376193 --- /dev/null +++ b/docs/tickets/771/tasks.md @@ -0,0 +1,71 @@ +# #771: Magistrates Standard List - Implementation Tasks + +## Implementation Tasks + +### Scaffold the library + +- [ ] Create directory `libs/list-types/magistrates-standard-list/src/` +- [ ] Create `libs/list-types/magistrates-standard-list/package.json` (copy from `civil-and-family-daily-cause-list`, update name and remove unneeded deps like `@hmcts/daily-cause-list-common`) +- [ ] Create `libs/list-types/magistrates-standard-list/tsconfig.json` (copy from `civil-and-family-daily-cause-list`) + +### JSON Schema and validation + +- [ ] Obtain the canonical `magistrates_standard_list.json` schema from pip-data-management +- [ ] Create `libs/list-types/magistrates-standard-list/src/schemas/magistrates-standard-list.json` +- [ ] Create `libs/list-types/magistrates-standard-list/src/validation/json-validator.ts` with `validateMagistratesStandardList()` +- [ ] Create `libs/list-types/magistrates-standard-list/src/validation/json-validator.test.ts` with valid and invalid fixture tests + +### TypeScript types + +- [ ] Create `libs/list-types/magistrates-standard-list/src/models/types.ts` with interfaces for `MagistratesStandardListData`, `MagistratesCourtList`, `MagistratesCourtHouse`, `MagistratesCourtRoom`, `MagistratesSitting`, `MagistratesHearing`, `MagistratesDefendant`, and `RenderOptions` — confirming field names against the actual JSON schema + +### Renderer + +- [ ] Create `libs/list-types/magistrates-standard-list/src/rendering/renderer.ts` with `renderMagistratesStandardListData()` that: + - Looks up the location name via `getLocationById` and picks Welsh name if `locale === "cy"` + - Formats `contentDate` and `lastUpdated` timestamps in the correct locale + - Annotates each defendant with a `formattedReportingRestriction` string (joined from `reportingRestrictionDetail` array) + - Returns `{ header, listData }` (no `openJustice` object — magistrates uses the restriction section instead) +- [ ] Create `libs/list-types/magistrates-standard-list/src/rendering/renderer.test.ts` covering: header formatting, Welsh locale, empty court list, defendant with reporting restriction, defendant without reporting restriction + +### Locale files (lib-level) + +- [ ] Create `libs/list-types/magistrates-standard-list/src/locales/en.ts` with `errorTitle`, `errorMessage`, `error403Title`, `error403Message` +- [ ] Create `libs/list-types/magistrates-standard-list/src/locales/cy.ts` with Welsh equivalents + +### PDF generator + +- [ ] Create `libs/list-types/magistrates-standard-list/src/pdf/pdf-template.njk` as a self-contained HTML template matching the HTML view layout, including the static reporting restriction section and the defendant hearings table +- [ ] Create `libs/list-types/magistrates-standard-list/src/pdf/pdf-generator.ts` with `generateMagistratesStandardListPdf()` following the same pattern as `generateCauseListPdf` in `civil-and-family-daily-cause-list` +- [ ] Create `libs/list-types/magistrates-standard-list/src/pdf/pdf-generator.test.ts` covering: successful generation, Welsh locale, PDF generation failure, renderer error, missing provenance + +### Config and index + +- [ ] Create `libs/list-types/magistrates-standard-list/src/config.ts` exporting `moduleRoot`, `assets`, `schemaPath` +- [ ] Create `libs/list-types/magistrates-standard-list/src/config.test.ts` verifying `moduleRoot` and `assets` paths +- [ ] Create `libs/list-types/magistrates-standard-list/src/index.ts` exporting `validateMagistratesStandardList`, `renderMagistratesStandardListData`, `generateMagistratesStandardListPdf`, locale exports (`magistratesStandardListEn`, `magistratesStandardListCy`), and all types +- [ ] Create `libs/list-types/magistrates-standard-list/src/index.test.ts` verifying all expected exports are present + +### Page controller and template + +- [ ] Create `apps/web/src/pages/(list-types)/magistrates-standard-list/en.ts` with all content keys from the ticket (title, listDate, lastUpdated, publishedAt, venueAddress, openJusticeTitle, dataSource, column headers, reporting restriction keys, noHearings, linkToTop, plus errorTitle/errorMessage) +- [ ] Create `apps/web/src/pages/(list-types)/magistrates-standard-list/cy.ts` with all Welsh content keys +- [ ] Create `apps/web/src/pages/(list-types)/magistrates-standard-list/magistrates-standard-list.njk` extending `layouts/base-template.njk`, including: venue header, reporting restriction section, Open Justice collapsible, case search input, court room accordion with defendant table, empty state message, back to top, data source attribution +- [ ] Create `apps/web/src/pages/(list-types)/magistrates-standard-list/index.ts` using `createListTypeHandler` with `validateMagistratesStandardList`, `renderMagistratesStandardListData`, `checkAccess: true` +- [ ] Create `apps/web/src/pages/(list-types)/magistrates-standard-list/index.test.ts` covering: missing artefactId (400), artefact not found (404), JSON blob not found (404), validation failure (400), access denied (403), successful render in English, successful render in Welsh, locale defaulting to English, 500 on unexpected error + +### Register the module + +- [ ] Add path alias to root `tsconfig.json`: `"@hmcts/magistrates-standard-list": ["libs/list-types/magistrates-standard-list/src"]` +- [ ] Add dependency to `apps/web/package.json`: `"@hmcts/magistrates-standard-list": "workspace:*"` +- [ ] Add `moduleRoot` import and entry to `modulePaths` array in `apps/web/src/app.ts` +- [ ] Add list type entry to `libs/location/src/list-type-data.ts` with the correct ID and `CRIME_IDAM` provenance +- [ ] Add dependency `@hmcts/magistrates-standard-list` to `libs/publication/package.json` +- [ ] Add `MAGISTRATES_STANDARD_LIST` entry to `PDF_GENERATOR_REGISTRY` in `libs/publication/src/processing/service.ts` + +### Verify + +- [ ] Run `yarn test` from the repo root and confirm all tests pass +- [ ] Verify the page renders at `GET /magistrates-standard-list?artefactId=` with a valid fixture +- [ ] Verify Welsh content at `GET /magistrates-standard-list?artefactId=&lng=cy` +- [ ] Verify 400 is returned when `artefactId` is absent diff --git a/docs/tickets/771/ticket.md b/docs/tickets/771/ticket.md new file mode 100644 index 000000000..c9c388ef5 --- /dev/null +++ b/docs/tickets/771/ticket.md @@ -0,0 +1,156 @@ +# #771: Style Guide: Implement Magistrates Standard List + +**State:** OPEN +**Assignees:** KianKwa +**Author:** junaidiqbalmoj +**Labels:** (none) +**Created:** 2026-06-25T13:48:43Z +**Updated:** 2026-06-30T08:21:04Z + +## Description + +## User Story + +As a user, I want to view the Magistrates Standard List in an accessible and well-formatted style, so that I can see scheduled hearings with all relevant case details including reporting restrictions. + +> See issue #596 for the magistrates-public-list implementation. + +## Background + +`magistrates-standard-list` has its own separate JSON schema. It follows the same module pattern as the existing `civil-and-family-daily-cause-list` implementation. All magistrates lists include reporting restriction information. + +## Acceptance Criteria + +### Module structure +- [ ] New lib created: `libs/list-types/magistrates-standard-list/` +- [ ] Lib contains: + - `src/models/types.ts`, `src/validation/json-validator.ts`, `src/schemas/magistrates-standard-list.json` + - `src/rendering/renderer.ts`, `renderer.test.ts` + - `src/pages/index.ts`, `index.test.ts`, `en.ts`, `cy.ts`, `magistrates-standard-list.njk` + - `src/pdf/pdf-generator.ts`, `pdf-template.njk`, `pdf-generator.test.ts` + - `src/index.ts`, `src/config.ts`, `package.json`, `tsconfig.json` + +### Page content (EN) + +| Key | English | +|-----|---------| +| title | Magistrates Standard List | +| listDate | List for date: | +| lastUpdated | Last updated: | +| publishedAt | Published at: | +| venueAddress | Venue address | +| openJusticeTitle | Open justice | +| dataSource | Data source | +| defendant | Defendant | +| caseNumber | Case number | +| offence | Offence | +| plea | Plea | +| results | Results | +| resultsProviso | Results proviso | +| noHearings | No hearings today | +| linkToTop | Back to top | + +### Page content (CY) + +| Key | Welsh | +|-----|-------| +| title | Rhestr Safonol Llys Ynadon | +| listDate | Rhestr ar gyfer dyddiad: | +| lastUpdated | Diweddarwyd ddiwethaf: | +| publishedAt | Cyhoeddwyd am: | +| venueAddress | Cyfeiriad y lleoliad | +| openJusticeTitle | Cyfiawnder agored | +| dataSource | Ffynhonnell data | +| defendant | Diffynnydd | +| caseNumber | Rhif yr achos | +| offence | Trosedd | +| plea | Ple | +| results | Canlyniadau | +| resultsProviso | Darpariaeth canlyniadau | +| noHearings | Dim gwrandawiadau heddiw | +| linkToTop | Yn ôl i'r brig | + +### Hearings table columns + +| Column | EN | CY | +|--------|----|----| +| Time | Time | Amser | +| Defendant name | Defendant name | Enw'r diffynnydd | +| Case number | Case number | Rhif yr achos | +| Offence | Offence | Trosedd | +| Plea | Plea | Ple | +| Results | Results | Canlyniadau | + +### Reporting restriction content (EN) + +| Key | English | +|-----|---------| +| restrictionInformationHeading | Restriction information | +| restrictionInformationP1 | In these cases, certain information may be subject to reporting restrictions. Such restrictions will be noted in the relevant entries. The Press and public should note the following before publishing any information about this matter: | +| restrictionInformationBoldText | Further information about reporting restrictions may be obtained from the Clerk of the Court before any publication is made. | +| restrictionInformationP2 | Section 49 of the Children and Young Persons Act 1933 provides that in any proceedings against a young person, the court shall not publish: | +| restrictionInformationP3 | Section 39 of the Children and Young Persons Act 1933 gives the court the power to impose restrictions preventing any reporting of cases involving children. If such an order has been made, the media should not publish: | +| restrictionInformationP4 | In cases where a defendant is found unfit to plead, Section 4A of the Criminal Procedure (Insanity) Act 1964 gives courts the power to impose restrictions. | +| restrictionBulletPoint1 | the name, address or school of the young person | +| restrictionBulletPoint2 | any particulars calculated to lead to the identification of the young person | + +### Reporting restriction content (CY) + +| Key | Welsh | +|-----|-------| +| restrictionInformationHeading | Gwybodaeth cyfyngiad | +| restrictionInformationP1 | Yn yr achosion hyn, efallai y bydd rhai gwybodaeth yn destun cyfyngiadau adrodd. Nodir cyfyngiadau o'r fath yn y cofnodion perthnasol. Dylai'r Wasg a'r cyhoedd nodi'r canlynol cyn cyhoeddi unrhyw wybodaeth am y mater hwn: | +| restrictionInformationBoldText | Gellir cael rhagor o wybodaeth am gyfyngiadau adrodd gan Glerc y Llys cyn gwneud unrhyw gyhoeddiad. | +| restrictionInformationP2 | Mae adran 49 o Ddeddf Plant a Phobl Ifanc 1933 yn darparu, mewn unrhyw achos yn erbyn person ifanc, na chaiff y llys gyhoeddi: | +| restrictionInformationP3 | Mae adran 39 o Ddeddf Plant a Phobl Ifanc 1933 yn rhoi pŵer i'r llys osod cyfyngiadau sy'n atal unrhyw adroddiad ar achosion yn ymwneud â phlant. Os gwnaed gorchymyn o'r fath, ni ddylai'r cyfryngau gyhoeddi: | +| restrictionInformationP4 | Mewn achosion lle canfyddir bod diffynnydd yn anghymwys i bledio, mae Adran 4A o Ddeddf Gweithdrefn Droseddol (Annhwylldeb) 1964 yn rhoi pŵer i lysoedd osod cyfyngiadau. | +| restrictionBulletPoint1 | enw, cyfeiriad neu ysgol y person ifanc | +| restrictionBulletPoint2 | unrhyw fanylion a allai arwain at adnabod y person ifanc | + +### Pages +- [ ] Page accessible at: `GET /magistrates-standard-list?artefactId=` +- [ ] Displays venue name, address, content date, last updated timestamp +- [ ] Hearings table shows correct columns (as defined above) +- [ ] Reporting restriction section displayed +- [ ] Open Justice collapsible section present +- [ ] Case search input present +- [ ] Data source attribution shown at bottom + +### Validation and access control +- [ ] Returns 400 if `artefactId` is missing +- [ ] Returns 404 if artefact not found +- [ ] Returns 403 if user does not have access +- [ ] Returns 400 if JSON fails schema validation + +### PDF generation +- [ ] PDF generated matching the HTML view structure +- [ ] PDF includes reporting restriction section +- [ ] PDF saved to storage correctly + +### Welsh language +- [ ] All page content available in Welsh via `?lng=cy` +- [ ] Reporting restriction content translated in Welsh +- [ ] PDF generated in correct language based on locale + +### Registration +- [ ] Module registered in `apps/web/src/app.ts` +- [ ] Path alias added to root `tsconfig.json` +- [ ] Package added as dependency in `apps/web/package.json` + +### Tests +- [ ] Unit tests pass +- [ ] `yarn test` passes across the workspace + +## TODO + +- [ ] Add email summary (`src/email-summary/summary-builder.ts`) once email summary requirements are confirmed + +## Technical Notes + +- Schema source from pip-data-management `src/main/resources/schemas/magistrates_standard_list.json` +- Follow the `civil-and-family-daily-cause-list` module as the reference implementation +- Includes the same reporting restriction section in both HTML and PDF output + +## Comments + +No comments on this issue. diff --git a/libs/list-types/magistrates-standard-list/package.json b/libs/list-types/magistrates-standard-list/package.json new file mode 100644 index 000000000..40dafd7a5 --- /dev/null +++ b/libs/list-types/magistrates-standard-list/package.json @@ -0,0 +1,27 @@ +{ + "name": "@hmcts/magistrates-standard-list", + "version": "1.0.0", + "type": "module", + "dependencies": { + "@hmcts/location": "workspace:*" + }, + "exports": { + ".": { + "production": "./dist/index.js", + "default": "./src/index.ts" + }, + "./config": { + "production": "./dist/config.js", + "default": "./src/config.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 ." + } +} diff --git a/libs/list-types/magistrates-standard-list/src/config.ts b/libs/list-types/magistrates-standard-list/src/config.ts new file mode 100644 index 000000000..196f69c27 --- /dev/null +++ b/libs/list-types/magistrates-standard-list/src/config.ts @@ -0,0 +1,8 @@ +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 schemaPath = path.join(__dirname, "schemas/magistrates-standard-list.json"); diff --git a/libs/list-types/magistrates-standard-list/src/email-summary/summary-builder.test.ts b/libs/list-types/magistrates-standard-list/src/email-summary/summary-builder.test.ts new file mode 100644 index 000000000..1479072f6 --- /dev/null +++ b/libs/list-types/magistrates-standard-list/src/email-summary/summary-builder.test.ts @@ -0,0 +1,184 @@ +import { describe, expect, it } from "vitest"; +import type { MagistratesStandardList } from "../models/types.js"; +import { extractCaseSummary } from "./summary-builder.js"; + +const BASE: MagistratesStandardList = { + document: { publicationDate: "2025-01-13T09:30:00.000Z" }, + venue: {}, + courtLists: [] +}; + +function makeJson( + hearings: MagistratesStandardList["courtLists"][0]["courtHouse"]["courtRoom"][0]["session"][0]["sittings"][0]["hearing"] +): MagistratesStandardList { + return { + ...BASE, + courtLists: [ + { + courtHouse: { + courtHouseName: "Test Court", + courtRoom: [ + { + courtRoomName: "Court 1", + session: [{ sittings: [{ sittingStart: "2025-01-13T10:00:00.000Z", hearing: hearings }] }] + } + ] + } + } + ] + }; +} + +describe("extractCaseSummary", () => { + it("should include middle name in defendant name", () => { + const result = extractCaseSummary( + makeJson([ + { + case: [ + { + caseUrn: "URN001", + party: [ + { + partyRole: "DEFENDANT", + individualDetails: { + individualForenames: "John", + individualMiddleName: "Edward", + individualSurname: "Smith" + } + } + ] + } + ] + } + ]) + ); + + expect(result[0].find((f) => f.label === "Name")?.value).toBe("Smith, John Edward"); + }); + + it("should include middle name in applicant name", () => { + const result = extractCaseSummary( + makeJson([ + { + application: [ + { + applicationReference: "APP001", + party: [ + { + subject: true, + individualDetails: { + individualForenames: "Jane", + individualMiddleName: "Marie", + individualSurname: "Doe" + } + } + ] + } + ] + } + ]) + ); + + expect(result[0].find((f) => f.label === "Name")?.value).toBe("Doe, Jane Marie"); + }); + + it("should join all offence titles with comma for defendant", () => { + const result = extractCaseSummary( + makeJson([ + { + case: [ + { + caseUrn: "URN002", + party: [ + { + partyRole: "DEFENDANT", + individualDetails: { individualForenames: "Bob", individualSurname: "Jones" }, + offence: [{ offenceTitle: "Drink driving" }, { offenceTitle: "Assault by beating" }, { offenceTitle: "Criminal damage" }] + } + ] + } + ] + } + ]) + ); + + expect(result[0].find((f) => f.label === "Offence")?.value).toBe("Drink driving, Assault by beating, Criminal damage"); + }); + + it("should display offences for application subject party", () => { + const result = extractCaseSummary( + makeJson([ + { + application: [ + { + applicationReference: "APP002", + party: [ + { + subject: true, + organisationDetails: { organisationName: "Respondent Ltd" }, + offence: [{ offenceTitle: "Breach of order" }, { offenceTitle: "Contempt of court" }] + } + ] + } + ] + } + ]) + ); + + expect(result[0].find((f) => f.label === "Offence")?.value).toBe("Breach of order, Contempt of court"); + }); + + it("should not include Offence field when no offences present", () => { + const result = extractCaseSummary( + makeJson([ + { + case: [ + { + caseUrn: "URN003", + party: [{ partyRole: "DEFENDANT", individualDetails: { individualForenames: "Ann", individualSurname: "Brown" } }] + } + ] + } + ]) + ); + + expect(result[0].find((f) => f.label === "Offence")).toBeUndefined(); + }); + + it("should include prosecuting authority for cases", () => { + const result = extractCaseSummary( + makeJson([ + { + case: [ + { + caseUrn: "URN004", + party: [ + { partyRole: "DEFENDANT", individualDetails: { individualForenames: "Tom", individualSurname: "Hill" } }, + { partyRole: "PROSECUTING_AUTHORITY", organisationDetails: { organisationName: "Crown Prosecution Service" } } + ] + } + ] + } + ]) + ); + + expect(result[0].find((f) => f.label === "Prosecuting authority")?.value).toBe("Crown Prosecution Service"); + }); + + it("should not include Offence field for applications with no offences", () => { + const result = extractCaseSummary( + makeJson([ + { + application: [ + { + applicationReference: "APP003", + party: [{ subject: true, organisationDetails: { organisationName: "Test Org" } }] + } + ] + } + ]) + ); + + expect(result[0].find((f) => f.label === "Offence")).toBeUndefined(); + }); +}); diff --git a/libs/list-types/magistrates-standard-list/src/email-summary/summary-builder.ts b/libs/list-types/magistrates-standard-list/src/email-summary/summary-builder.ts new file mode 100644 index 000000000..f8ddd8da4 --- /dev/null +++ b/libs/list-types/magistrates-standard-list/src/email-summary/summary-builder.ts @@ -0,0 +1,95 @@ +import { type CaseSummary, formatCaseSummaryForEmail } from "@hmcts/list-types-common"; +import type { MagistratesStandardList } from "../models/types.js"; + +export { formatCaseSummaryForEmail }; + +function extractPartyName(party: { + partyRole?: string; + subject?: boolean; + individualDetails?: { individualForenames?: string; individualMiddleName?: string; individualSurname?: string }; + organisationDetails?: { organisationName: string }; +}): string { + if (party.individualDetails) { + const { individualForenames, individualMiddleName, individualSurname } = party.individualDetails; + const forenames = [individualForenames, individualMiddleName].filter(Boolean).join(" "); + const parts = [individualSurname, forenames].filter(Boolean); + return parts.join(", "); + } + if (party.organisationDetails) { + return party.organisationDetails.organisationName; + } + return ""; +} + +function extractOffenceTitles(party: { offence?: { offenceTitle?: string }[] }): string { + const titles = (party.offence ?? []).map((o) => o.offenceTitle).filter(Boolean) as string[]; + return titles.join(", "); +} + +export function extractCaseSummary(jsonData: MagistratesStandardList): CaseSummary[] { + const summaries: CaseSummary[] = []; + + for (const courtList of jsonData.courtLists) { + for (const courtRoom of courtList.courtHouse.courtRoom) { + for (const session of courtRoom.session) { + for (const sitting of session.sittings) { + for (const hearing of sitting.hearing) { + for (const caseItem of hearing.case ?? []) { + const defendant = caseItem.party?.find((p) => p.partyRole === "DEFENDANT"); + const prosecutor = caseItem.party?.find((p) => p.partyRole === "PROSECUTING_AUTHORITY"); + const fields: CaseSummary = []; + + if (defendant) { + const name = extractPartyName(defendant); + if (name) fields.push({ label: "Name", value: name }); + } + + if (prosecutor) { + const authority = extractPartyName(prosecutor); + if (authority) fields.push({ label: "Prosecuting authority", value: authority }); + } + + fields.push({ label: "Reference", value: caseItem.caseUrn }); + + if (hearing.hearingType) { + fields.push({ label: "Hearing type", value: hearing.hearingType }); + } + + if (defendant) { + const offences = extractOffenceTitles(defendant); + if (offences) fields.push({ label: "Offence", value: offences }); + } + + summaries.push(fields); + } + + for (const application of hearing.application ?? []) { + const subject = application.party?.find((p) => p.subject === true); + const fields: CaseSummary = []; + + if (subject) { + const name = extractPartyName(subject); + if (name) fields.push({ label: "Name", value: name }); + } + + fields.push({ label: "Reference", value: application.applicationReference }); + + if (hearing.hearingType) { + fields.push({ label: "Hearing type", value: hearing.hearingType }); + } + + if (subject) { + const offences = extractOffenceTitles(subject); + if (offences) fields.push({ label: "Offence", value: offences }); + } + + summaries.push(fields); + } + } + } + } + } + } + + return summaries; +} diff --git a/libs/list-types/magistrates-standard-list/src/index.ts b/libs/list-types/magistrates-standard-list/src/index.ts new file mode 100644 index 000000000..2e83edf66 --- /dev/null +++ b/libs/list-types/magistrates-standard-list/src/index.ts @@ -0,0 +1,7 @@ +export * from "./email-summary/summary-builder.js"; +export { cy as magistratesStandardListCy } from "./locales/cy.js"; +export { en as magistratesStandardListEn } from "./locales/en.js"; +export * from "./models/types.js"; +export * from "./pdf/pdf-generator.js"; +export * from "./rendering/renderer.js"; +export { validateMagistratesStandardList } from "./validation/json-validator.js"; diff --git a/libs/list-types/magistrates-standard-list/src/locales/cy.ts b/libs/list-types/magistrates-standard-list/src/locales/cy.ts new file mode 100644 index 000000000..ac21a7523 --- /dev/null +++ b/libs/list-types/magistrates-standard-list/src/locales/cy.ts @@ -0,0 +1,52 @@ +export const cy = { + title: "Rhestr Safonol y Llys Ynadon", + pageTitle: "Rhestr Safonol y Llys Ynadon ar gyfer", + 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.", + listDate: "Rhestr ar gyfer", + lastUpdated: "Diweddarwyd diwethaf:", + publishedAt: "am", + restrictionInformationHeading: "Cyfyngiadau ar gyhoeddi neu ysgrifennu am yr achosion hyn.", + restrictionInformationP1: + "Rhaid i chi wirio a oes unrhyw gyfyngiadau riportio yn berthnasol cyn cyhoeddi manylion am unrhyw un o'r achosion a restrir yma, naill ai'n ysgrifenedig, mewn darllediad neu ar y rhyngrwyd, gan gynnwys y cyfryngau cymdeithasol.", + restrictionInformationBoldText: + "Byddwch yn euog o ddirmyg llys os byddwch yn cyhoeddi unrhyw wybodaeth sydd wedi'i diogelu gan gyfyngiad riportio. Gallwch gael dirwy, eich dedfrydu i garchar, neu'r ddau.", + restrictionInformationP2: "Bydd cyfyngiadau penodol a orchmynnir gan y llys yn cael eu crybwyll ar yr achosion a restrir yma.", + restrictionInformationP3: + "Fodd bynnag, nid yw'r cyfyngiadau bob amser yn cael eu rhestru. Mae rhai yn berthnasol yn awtomatig. Er enghraifft, anhysbysrwydd a roddir i ddioddefwyr rhai troseddau rhywiol.", + restrictionInformationP4: "I ganfod pa gyfyngiadau riportio sy'n berthnasol ar achos penodol, cysylltwch â'r:", + restrictionBulletPoint1: "llys yn uniongyrchol", + restrictionBulletPoint2: "Gwasanaeth Llysoedd a Thribiwnlysoedd EM ar 0330 808 4407", + name: "Enw'r: ", + sittingAt: "Yn eistedd yn ", + reference: "Cyfeirnod: ", + applicationType: "Math o Gais: ", + dobAndAge: "Dyddiad Geni ac Oedran: ", + age: "Oedran:", + asn: "ASN (Rhif Gwŷs Arestio): ", + pncId: "PNC ID: ", + address: "Cyfeiriad: ", + hearingType: "Math o Wrandawiad: ", + prosecutingAuthority: "Prosecuting Authority Name: ", + panel: "Panel: ", + attendanceMethod: "Dull Presenoldeb: ", + reportingRestrictions: "Cyfyngiadau Riportio: ", + applicationParticulars: "Manylion y Cais: ", + offenceCode: "Cod Trosedd", + plea: "Ple", + dateOfPlea: "Dyddiad Pledio", + convictedOn: "Cafwyd yn euog ar", + adjournedFrom: "Wedi'i ohirio o", + adjournedText: "Ar gyfer y treial", + legislation: "Deddfwriaeth", + maxPenalty: "Cosb Uchaf", + lja: "LJA", + dataSource: "Ffynhonnell Data", + searchCases: "Chwilio achosion", + noHearings: "Dim gwrandawiadau heddiw", + errorTitle: "Nid yw'r cyhoeddiad ar gael", + errorMessage: "Ni ellir gweld y cyhoeddiad hwn ar hyn o bryd. Gwiriwch eto yn nes ymlaen. Os bydd y broblem yn parhau, cysylltwch â'r llys yn uniongyrchol.", + error403Title: "Mynediad wedi'i wrthod", + error403Message: "Nid oes gennych ganiatâd i weld y cyhoeddiad hwn." +}; diff --git a/libs/list-types/magistrates-standard-list/src/locales/en.ts b/libs/list-types/magistrates-standard-list/src/locales/en.ts new file mode 100644 index 000000000..38601c8c1 --- /dev/null +++ b/libs/list-types/magistrates-standard-list/src/locales/en.ts @@ -0,0 +1,53 @@ +export const en = { + title: "Magistrates Standard List", + pageTitle: "Magistrates Standard List for", + 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.", + listDate: "List for", + lastUpdated: "Last updated:", + publishedAt: "at", + restrictionInformationHeading: "Restrictions on publishing or writing about these cases", + restrictionInformationP1: + "You must check if any reporting restrictions apply before publishing details on any of the cases listed here either in writing, in a broadcast or by internet, including social media.", + restrictionInformationBoldText: + "You'll be in contempt of court if you publish any information which is protected by a reporting restriction. You could get a fine, prison sentence or both.", + restrictionInformationP2: "Specific restrictions ordered by the court will be mentioned on the cases listed here.", + restrictionInformationP3: + "However, restrictions are not always listed. Some apply automatically. For example, anonymity given to the victims of certain sexual offences.", + restrictionInformationP4: "To find out which reporting restrictions apply on a specific case, contact:", + restrictionBulletPoint1: "the court directly", + restrictionBulletPoint2: "HM Courts and Tribunals Service on 0330 808 4407", + name: "Name: ", + sittingAt: "Sitting at ", + reference: "Reference: ", + applicationType: "Application Type: ", + dobAndAge: "DOB and Age: ", + age: "Age:", + asn: "ASN: ", + pncId: "PNC ID: ", + address: "Address: ", + hearingType: "Hearing Type: ", + prosecutingAuthority: "Prosecuting Authority Name: ", + panel: "Panel: ", + attendanceMethod: "Attendance Method: ", + reportingRestrictions: "Reporting Restrictions: ", + applicationParticulars: "Application Particulars: ", + offenceCode: "Offence Code", + plea: "Plea", + dateOfPlea: "Date of Plea", + convictedOn: "Convicted on", + adjournedFrom: "Adjourned from", + adjournedText: "For the trial", + legislation: "Legislation", + maxPenalty: "Max Penalty", + lja: "LJA", + dataSource: "Data Source", + searchCases: "Search cases", + noHearings: "No hearings today", + errorTitle: "Publication not available", + errorMessage: + "This publication cannot be viewed at the moment. Please check again later. If the problem persists, contact the court directly for assistance.", + error403Title: "Access Denied", + error403Message: "You do not have permission to view this publication." +}; diff --git a/libs/list-types/magistrates-standard-list/src/models/types.ts b/libs/list-types/magistrates-standard-list/src/models/types.ts new file mode 100644 index 000000000..babc07d58 --- /dev/null +++ b/libs/list-types/magistrates-standard-list/src/models/types.ts @@ -0,0 +1,168 @@ +export interface MagistratesStandardList { + document: { + publicationDate: string; + }; + venue: { + venueAddress?: Address; + }; + courtLists: CourtList[]; +} + +export interface CourtList { + courtHouse: { + courtHouseName?: string; + lja?: string; + courtRoom: CourtRoom[]; + }; +} + +export interface CourtRoom { + courtRoomName: string; + session: Session[]; +} + +export interface Session { + judiciary?: Judiciary[]; + sittings: Sitting[]; +} + +export interface Judiciary { + johKnownAs?: string; + isPresiding?: boolean; +} + +export interface Sitting { + sittingStart: string; + hearing: Hearing[]; +} + +export interface Hearing { + hearingType?: string; + panel?: string; + channel?: string[]; + case?: Case[]; + application?: Application[]; +} + +export interface Case { + caseUrn: string; + reportingRestriction?: boolean; + reportingRestrictionDetails?: string[]; + caseSequenceIndicator?: string; + party?: Party[]; +} + +export interface Application { + applicationReference: string; + applicationType?: string; + applicationParticulars?: string; + reportingRestriction?: boolean; + reportingRestrictionDetails?: string[]; + party?: Party[]; +} + +export interface Party { + partyRole?: string; + subject?: boolean; + individualDetails?: IndividualDetails; + organisationDetails?: OrganisationDetails; + offence?: Offence[]; +} + +export interface IndividualDetails { + individualForenames?: string; + individualMiddleName?: string; + individualSurname?: string; + dateOfBirth?: string; + age?: number; + address?: Address; + inCustody?: boolean; + gender?: string; + asn?: string; + pncId?: string; +} + +export interface OrganisationDetails { + organisationName: string; + organisationAddress?: Address; +} + +export interface Offence { + offenceCode?: string; + offenceTitle?: string; + offenceWording?: string; + offenceMaxPen?: string; + reportingRestriction?: boolean; + reportingRestrictionDetails?: string[]; + convictionDate?: string; + adjournedDate?: string; + plea?: string; + pleaDate?: string; + offenceLegislation?: string; +} + +export interface Address { + line?: string[]; + town?: string; + county?: string; + postCode?: string; +} + +export interface RenderedMagistratesStandardListHeader { + locationName: string; + contentDate: string; + publishedDate: string; + publishedTime: string; + venueAddress: string[]; +} + +export interface RenderedCourtRoom { + courtHouseName: string; + courtRoomName: string; + lja: string; + sittings: RenderedSitting[]; +} + +export interface RenderedSitting { + sittingHeading: string; + hearings: RenderedHearing[]; +} + +export interface RenderedHearing { + partyInfo: RenderedPartyInfo; + sittingStartTime: string; + prosecutingAuthority: string; + attendanceMethod: string; + reference: string; + applicationType: string; + caseSequenceIndicator: string; + hearingType: string; + panel: string; + applicationParticulars: string; + reportingRestriction: boolean; + reportingRestrictionDetails: string; + offences: RenderedOffence[]; +} + +export interface RenderedPartyInfo { + name: string; + dob: string; + age: string; + address: string; + asn: string; + pncId: string; +} + +export interface RenderedOffence { + offenceCode: string; + offenceTitle: string; + offenceWording: string; + plea: string; + pleaDate: string; + convictionDate: string; + adjournedDate: string; + offenceLegislation: string; + offenceMaxPenalty: string; + reportingRestriction: boolean; + reportingRestrictionDetails: string; +} diff --git a/libs/list-types/magistrates-standard-list/src/pdf/pdf-generator.ts b/libs/list-types/magistrates-standard-list/src/pdf/pdf-generator.ts new file mode 100644 index 000000000..7bc0da462 --- /dev/null +++ b/libs/list-types/magistrates-standard-list/src/pdf/pdf-generator.ts @@ -0,0 +1,59 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { + type BasePdfGenerationOptions, + configureNunjucks, + createPdfErrorResult, + loadTranslations, + PDF_BASE_STYLES, + type PdfGenerationResult, + savePdfToStorage +} from "@hmcts/list-types-common"; +import { generatePdfFromHtml } from "@hmcts/pdf-generation"; +import { PROVENANCE_LABELS } from "@hmcts/publication"; +import type { MagistratesStandardList } from "../models/types.js"; +import { renderMagistratesStandardListData } from "../rendering/renderer.js"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +interface PdfGenerationOptions extends BasePdfGenerationOptions { + contentDate: Date; +} + +export async function generateMagistratesStandardListPdf(options: PdfGenerationOptions): Promise { + try { + const renderedData = await renderMagistratesStandardListData(options.jsonData, { + locale: options.locale, + locationId: options.locationId, + contentDate: options.contentDate + }); + + const translations = await loadTranslations( + options.locale, + () => import("../locales/en.js"), + () => import("../locales/cy.js") + ); + + const provenanceLabel = options.provenance ? PROVENANCE_LABELS[options.provenance as keyof typeof PROVENANCE_LABELS] || options.provenance : ""; + + const env = configureNunjucks(__dirname); + const html = env.render("pdf-template.njk", { + header: renderedData.header, + listData: renderedData.listData, + dataSource: provenanceLabel, + t: translations, + pdfStyles: PDF_BASE_STYLES + }); + + const pdfResult = await generatePdfFromHtml(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/magistrates-standard-list/src/pdf/pdf-template.njk b/libs/list-types/magistrates-standard-list/src/pdf/pdf-template.njk new file mode 100644 index 000000000..ba7e14ace --- /dev/null +++ b/libs/list-types/magistrates-standard-list/src/pdf/pdf-template.njk @@ -0,0 +1,173 @@ + + + + + {{ t.title }} + + + + +
+

{{ t.pageTitle }} {{ header.locationName }}

+

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

+
+

{{ t.listDate }} {{ header.contentDate }}

+

{{ t.lastUpdated }} {{ header.publishedDate }} {{ t.publishedAt }} {{ header.publishedTime }}

+
+ {% if header.venueAddress.length > 0 %} +

{% for line in header.venueAddress %}{{ line }}{% if not loop.last %}
{% endif %}{% endfor %}

+ {% endif %} +
+ +
+

{{ t.restrictionInformationHeading }}

+

{{ t.restrictionInformationP1 }}

+
{{ t.restrictionInformationBoldText }}
+

{{ t.restrictionInformationP2 }}

+

{{ t.restrictionInformationP3 }}

+

{{ t.restrictionInformationP4 }}

+
    +
  • {{ t.restrictionBulletPoint1 }}
  • +
  • {{ t.restrictionBulletPoint2 }}
  • +
+
+ + {% if listData.length === 0 %} +

{{ t.noHearings }}

+ {% endif %} + + {% for room in listData %} +
+ {% if room.courtHouseName %} +
{{ room.courtHouseName }}
+ {% endif %} + {% if room.lja %} +
{{ t.lja }}: {{ room.lja }}
+ {% endif %} +
{{ room.courtRoomName }}
+ + {% for sitting in room.sittings %} +
+
{{ t.sittingAt }}{{ sitting.sittingHeading }}
+ + {% for hearing in sitting.hearings %} +
+
+
+

{{ t.name }}{{ hearing.partyInfo.name }}

+ {% if hearing.applicationParticulars %} +

{{ t.applicationParticulars }}{{ hearing.applicationParticulars }}

+ {% endif %} + {% if hearing.partyInfo.dob or hearing.partyInfo.age %} +

+ {{ t.dobAndAge }} + {% if hearing.partyInfo.dob %}{{ hearing.partyInfo.dob }}{% endif %} + {% if hearing.partyInfo.age %} {{ t.age }} {{ hearing.partyInfo.age }}{% endif %} +

+ {% endif %} + {% if hearing.partyInfo.address %} +

{{ t.address }}{{ hearing.partyInfo.address }}

+ {% endif %} + {% if hearing.prosecutingAuthority %} +

{{ t.prosecutingAuthority }}{{ hearing.prosecutingAuthority }}

+ {% endif %} + {% if hearing.attendanceMethod %} +

{{ t.attendanceMethod }}{{ hearing.attendanceMethod }}

+ {% endif %} + {% if hearing.reportingRestriction %} +

{{ t.reportingRestrictions }}{{ hearing.reportingRestrictionDetails }}

+ {% endif %} +
+
+ {% if hearing.reference %} +

{{ t.reference }}{{ hearing.reference }}

+ {% endif %} + {% if hearing.applicationType %} +

{{ t.applicationType }}{{ hearing.applicationType }}

+ {% endif %} +

{{ t.asn }}{{ hearing.partyInfo.asn }}

+

{{ t.pncId }}{{ hearing.partyInfo.pncId }}

+ {% if hearing.hearingType %} +

{{ t.hearingType }}{{ hearing.hearingType }}

+ {% endif %} + {% if hearing.panel %} +

{{ t.panel }}{{ hearing.panel }}

+ {% endif %} +
+
+ + {% set offenceCount = 0 %} + {% for offence in hearing.offences %} + {% if offence.offenceTitle %} + {% set offenceCount = offenceCount + 1 %} +
+

{{ offenceCount }}. {% if offence.offenceCode %}{{ offence.offenceCode }} - {% endif %}{{ offence.offenceTitle }}

+ {% if offence.offenceWording %}

{{ offence.offenceWording }}

{% endif %} + {% if offence.offenceLegislation %}

{{ t.legislation }}: {{ offence.offenceLegislation }}

{% endif %} + {% if offence.offenceMaxPenalty %}

{{ t.maxPenalty }}: {{ offence.offenceMaxPenalty }}

{% endif %} + {% if offence.plea %}

{{ t.plea }}: {{ offence.plea }}

{% endif %} + {% if offence.pleaDate %}

{{ t.dateOfPlea }}: {{ offence.pleaDate }}

{% endif %} + {% if offence.convictionDate %}

{{ t.convictedOn }}: {{ offence.convictionDate }}

{% endif %} + {% if offence.adjournedDate %}

{{ t.adjournedFrom }}: {{ offence.adjournedDate }} - {{ t.adjournedText }}

{% endif %} + {% if offence.reportingRestriction %}

{{ t.reportingRestrictions }} {{ offence.reportingRestrictionDetails }}

{% endif %} +
+ {% endif %} + {% endfor %} +
+ {% endfor %} +
+ {% endfor %} +
+ {% endfor %} + + + + diff --git a/libs/list-types/magistrates-standard-list/src/rendering/renderer.test.ts b/libs/list-types/magistrates-standard-list/src/rendering/renderer.test.ts new file mode 100644 index 000000000..c80aa5b3d --- /dev/null +++ b/libs/list-types/magistrates-standard-list/src/rendering/renderer.test.ts @@ -0,0 +1,481 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { MagistratesStandardList } from "../models/types.js"; +import { renderMagistratesStandardListData } from "./renderer.js"; + +vi.mock("@hmcts/location", () => ({ + getLocationById: vi.fn() +})); + +import { getLocationById } from "@hmcts/location"; + +const MINIMAL_JSON: MagistratesStandardList = { + document: { + publicationDate: "2025-01-13T09:30:00.000Z" + }, + venue: { + venueAddress: { + line: ["THE LAW COURTS", "CROWN SQUARE"], + town: "Manchester", + county: "Greater Manchester", + postCode: "M3 3FL" + } + }, + courtLists: [] +}; + +const FULL_JSON: MagistratesStandardList = { + document: { + publicationDate: "2025-01-13T09:30:00.000Z" + }, + venue: { + venueAddress: { + line: ["THE LAW COURTS"], + town: "Manchester", + postCode: "M3 3FL" + } + }, + courtLists: [ + { + courtHouse: { + courtHouseName: "Manchester Magistrates Court", + lja: "Greater Manchester", + courtRoom: [ + { + courtRoomName: "Court 1", + session: [ + { + judiciary: [{ johKnownAs: "District Judge Smith", isPresiding: true }], + sittings: [ + { + sittingStart: "2025-01-13T10:00:00.000Z", + hearing: [ + { + hearingType: "First hearing", + panel: "ADULT", + channel: ["VIDEO HEARING"], + case: [ + { + caseUrn: "URN12345", + reportingRestriction: false, + party: [ + { + partyRole: "DEFENDANT", + individualDetails: { + individualForenames: "John", + individualSurname: "Smith", + dateOfBirth: "1990-05-15", + age: 34, + address: { + line: ["12 High Street"], + town: "Salford", + postCode: "M5 1AB" + }, + asn: "ASN123456", + pncId: "PNC789" + }, + offence: [ + { + offenceCode: "DD01", + offenceTitle: "Drink driving", + offenceWording: "Driving whilst over the legal alcohol limit", + offenceMaxPen: "6 months imprisonment", + plea: "GUILTY", + pleaDate: "2025-01-10T00:00:00.000Z", + offenceLegislation: "Road Traffic Act 1988" + } + ] + }, + { + partyRole: "PROSECUTING_AUTHORITY", + organisationDetails: { + organisationName: "Crown Prosecution Service" + } + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + } + ] +}; + +describe("renderMagistratesStandardListData", () => { + beforeEach(() => { + (getLocationById as ReturnType).mockResolvedValue({ name: "Manchester Magistrates Court", welshName: "Llys Ynadon Manceinion" }); + }); + + it("should return correct header structure with minimal JSON", async () => { + const result = await renderMagistratesStandardListData(MINIMAL_JSON, { + locale: "en", + locationId: "123", + contentDate: new Date("2025-01-13") + }); + + expect(result.header).toHaveProperty("locationName"); + expect(result.header).toHaveProperty("contentDate"); + expect(result.header).toHaveProperty("publishedDate"); + expect(result.header).toHaveProperty("publishedTime"); + expect(result.header).toHaveProperty("venueAddress"); + }); + + it("should resolve location name from locationId", async () => { + const result = await renderMagistratesStandardListData(MINIMAL_JSON, { + locale: "en", + locationId: "123", + contentDate: new Date("2025-01-13") + }); + + expect(result.header.locationName).toBe("Manchester Magistrates Court"); + }); + + it("should use Welsh location name for cy locale", async () => { + const result = await renderMagistratesStandardListData(MINIMAL_JSON, { + locale: "cy", + locationId: "123", + contentDate: new Date("2025-01-13") + }); + + expect(result.header.locationName).toBe("Llys Ynadon Manceinion"); + }); + + it("should fall back to empty string when location not found", async () => { + (getLocationById as ReturnType).mockResolvedValue(undefined); + + const result = await renderMagistratesStandardListData(MINIMAL_JSON, { + locale: "en", + locationId: "999", + contentDate: new Date("2025-01-13") + }); + + expect(result.header.locationName).toBe(""); + }); + + it("should format header dates and times correctly", async () => { + const result = await renderMagistratesStandardListData(MINIMAL_JSON, { + locale: "en", + locationId: "123", + contentDate: new Date("2025-01-13T00:00:00.000Z") + }); + + expect(result.header.publishedDate).toContain("2025"); + expect(result.header.publishedTime).toMatch(/^\d{1,2}:\d{2}(am|pm)$/); + expect(result.header.venueAddress).toContain("Manchester"); + }); + + it("should return empty listData for empty courtLists", async () => { + const result = await renderMagistratesStandardListData(MINIMAL_JSON, { + locale: "en", + locationId: "123", + contentDate: new Date("2025-01-13") + }); + + expect(result.listData).toHaveLength(0); + }); + + it("should correctly render a court room with one hearing", async () => { + const result = await renderMagistratesStandardListData(FULL_JSON, { + locale: "en", + locationId: "123", + contentDate: new Date("2025-01-13T00:00:00.000Z") + }); + + expect(result.listData).toHaveLength(1); + const room = result.listData[0]; + expect(room.courtHouseName).toBe("Manchester Magistrates Court"); + expect(room.lja).toBe("Greater Manchester"); + expect(room.courtRoomName).toBe("Court 1: District Judge Smith"); + }); + + it("should include judiciary names in the court room name", async () => { + const result = await renderMagistratesStandardListData(FULL_JSON, { + locale: "en", + locationId: "123", + contentDate: new Date("2025-01-13T00:00:00.000Z") + }); + + expect(result.listData[0].courtRoomName).toBe("Court 1: District Judge Smith"); + }); + + it("should format individual party name as Surname, Forename", async () => { + const result = await renderMagistratesStandardListData(FULL_JSON, { + locale: "en", + locationId: "123", + contentDate: new Date("2025-01-13T00:00:00.000Z") + }); + + const hearing = result.listData[0].sittings[0].hearings[0]; + expect(hearing.partyInfo.name).toBe("Smith, John"); + }); + + it("should include gender in individual name when present", async () => { + const jsonWithGender: MagistratesStandardList = { + ...FULL_JSON, + courtLists: [ + { + courtHouse: { + courtHouseName: "Test Court", + courtRoom: [ + { + courtRoomName: "Court 1", + session: [ + { + sittings: [ + { + sittingStart: "2025-01-13T10:00:00.000Z", + hearing: [ + { + case: [ + { + caseUrn: "TEST001", + party: [ + { + partyRole: "DEFENDANT", + individualDetails: { + individualForenames: "Jane", + individualSurname: "Doe", + gender: "female" + } + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + } + ] + }; + + const result = await renderMagistratesStandardListData(jsonWithGender, { + locale: "en", + locationId: "123", + contentDate: new Date("2025-01-13T00:00:00.000Z") + }); + + expect(result.listData[0].sittings[0].hearings[0].partyInfo.name).toBe("Doe, Jane (female)"); + }); + + it("should mark in-custody defendants with an asterisk", async () => { + const jsonWithCustody: MagistratesStandardList = { + ...FULL_JSON, + courtLists: [ + { + courtHouse: { + courtHouseName: "Test Court", + courtRoom: [ + { + courtRoomName: "Court 1", + session: [ + { + sittings: [ + { + sittingStart: "2025-01-13T10:00:00.000Z", + hearing: [ + { + case: [ + { + caseUrn: "TEST002", + party: [ + { + partyRole: "DEFENDANT", + individualDetails: { + individualForenames: "Bob", + individualSurname: "Jones", + inCustody: true + } + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + } + ] + }; + + const result = await renderMagistratesStandardListData(jsonWithCustody, { + locale: "en", + locationId: "123", + contentDate: new Date("2025-01-13T00:00:00.000Z") + }); + + expect(result.listData[0].sittings[0].hearings[0].partyInfo.name).toBe("Jones, Bob*"); + }); + + it("should process offences correctly", async () => { + const result = await renderMagistratesStandardListData(FULL_JSON, { + locale: "en", + locationId: "123", + contentDate: new Date("2025-01-13T00:00:00.000Z") + }); + + const offences = result.listData[0].sittings[0].hearings[0].offences; + expect(offences).toHaveLength(1); + expect(offences[0].offenceCode).toBe("DD01"); + expect(offences[0].offenceTitle).toBe("Drink driving"); + expect(offences[0].offenceLegislation).toBe("Road Traffic Act 1988"); + expect(offences[0].plea).toBe("GUILTY"); + expect(offences[0].pleaDate).toBe("10/01/2025"); + expect(offences[0].offenceMaxPenalty).toBe("6 months imprisonment"); + }); + + it("should include reporting restriction details on case", async () => { + const jsonWithRestriction: MagistratesStandardList = { + ...FULL_JSON, + courtLists: [ + { + courtHouse: { + courtHouseName: "Test Court", + courtRoom: [ + { + courtRoomName: "Court 1", + session: [ + { + sittings: [ + { + sittingStart: "2025-01-13T10:00:00.000Z", + hearing: [ + { + case: [ + { + caseUrn: "TEST003", + reportingRestriction: true, + reportingRestrictionDetails: ["Restriction A", "Restriction B"], + party: [ + { + partyRole: "DEFENDANT", + individualDetails: { + individualForenames: "Test", + individualSurname: "Person" + } + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + } + ] + }; + + const result = await renderMagistratesStandardListData(jsonWithRestriction, { + locale: "en", + locationId: "123", + contentDate: new Date("2025-01-13T00:00:00.000Z") + }); + + const hearing = result.listData[0].sittings[0].hearings[0]; + expect(hearing.reportingRestriction).toBe(true); + expect(hearing.reportingRestrictionDetails).toBe("Restriction A, Restriction B"); + }); + + it("should process applications with subject party", async () => { + const jsonWithApplication: MagistratesStandardList = { + document: { publicationDate: "2025-01-13T09:30:00.000Z" }, + venue: {}, + courtLists: [ + { + courtHouse: { + courtHouseName: "Test Court", + courtRoom: [ + { + courtRoomName: "Court 1", + session: [ + { + sittings: [ + { + sittingStart: "2025-01-13T10:00:00.000Z", + hearing: [ + { + application: [ + { + applicationReference: "APP001", + applicationType: "Restraining Order", + applicationParticulars: "Urgent application", + party: [ + { + subject: true, + organisationDetails: { + organisationName: "Respondent Ltd" + } + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + } + ] + }; + + const result = await renderMagistratesStandardListData(jsonWithApplication, { + locale: "en", + locationId: "123", + contentDate: new Date("2025-01-13T00:00:00.000Z") + }); + + expect(result.listData).toHaveLength(1); + const hearing = result.listData[0].sittings[0].hearings[0]; + expect(hearing.partyInfo.name).toBe("Respondent Ltd"); + expect(hearing.reference).toBe("APP001"); + expect(hearing.applicationType).toBe("Restraining Order"); + expect(hearing.applicationParticulars).toBe("Urgent application"); + }); + + it("should find prosecuting authority from parties", async () => { + const result = await renderMagistratesStandardListData(FULL_JSON, { + locale: "en", + locationId: "123", + contentDate: new Date("2025-01-13T00:00:00.000Z") + }); + + const hearing = result.listData[0].sittings[0].hearings[0]; + expect(hearing.prosecutingAuthority).toBe("Crown Prosecution Service"); + }); + + it("should format attendance method from channels", async () => { + const result = await renderMagistratesStandardListData(FULL_JSON, { + locale: "en", + locationId: "123", + contentDate: new Date("2025-01-13T00:00:00.000Z") + }); + + const hearing = result.listData[0].sittings[0].hearings[0]; + expect(hearing.attendanceMethod).toBe("VIDEO HEARING"); + }); +}); diff --git a/libs/list-types/magistrates-standard-list/src/rendering/renderer.ts b/libs/list-types/magistrates-standard-list/src/rendering/renderer.ts new file mode 100644 index 000000000..6cf3e66fa --- /dev/null +++ b/libs/list-types/magistrates-standard-list/src/rendering/renderer.ts @@ -0,0 +1,298 @@ +import { getLocationById } from "@hmcts/location"; +import type { + Application, + Case, + CourtList, + Hearing, + IndividualDetails, + Judiciary, + MagistratesStandardList, + OrganisationDetails, + Party, + RenderedCourtRoom, + RenderedHearing, + RenderedMagistratesStandardListHeader, + RenderedOffence, + RenderedPartyInfo, + RenderedSitting, + Session, + Sitting +} from "../models/types.js"; + +export interface RenderOptions { + locale: string; + locationId: string; + contentDate: Date; +} + +export interface RenderedMagistratesStandardListData { + header: RenderedMagistratesStandardListHeader; + listData: RenderedCourtRoom[]; +} + +export async function renderMagistratesStandardListData( + jsonData: MagistratesStandardList, + options: RenderOptions +): Promise { + const header = await buildHeader(jsonData, options); + const listData = processCourtLists(jsonData.courtLists, options.locale); + return { header, listData }; +} + +async function buildHeader(jsonData: MagistratesStandardList, options: RenderOptions): Promise { + const pubDateTime = jsonData.document.publicationDate; + const { date: publishedDate, time: publishedTime } = formatDateAndTime(pubDateTime, options.locale); + const location = await getLocationById(Number.parseInt(options.locationId, 10)); + const locationName = options.locale === "cy" && location?.welshName ? location.welshName : (location?.name ?? ""); + return { + locationName, + contentDate: formatDate(options.contentDate, options.locale), + publishedDate, + publishedTime, + venueAddress: formatAddressLines(jsonData.venue?.venueAddress) + }; +} + +function processCourtLists(courtLists: CourtList[], locale: string): RenderedCourtRoom[] { + const result: RenderedCourtRoom[] = []; + + for (const courtList of courtLists) { + const { courtHouse } = courtList; + for (const courtRoom of courtHouse.courtRoom) { + for (const session of courtRoom.session) { + const sittings: RenderedSitting[] = []; + for (const sitting of session.sittings) { + processSitting(sitting, courtHouse, session, sittings, locale); + } + + if (sittings.length === 0) continue; + + const courtRoomName = formatCourtRoomWithJudiciary(courtRoom.courtRoomName, session.judiciary ?? []); + const courtHouseName = courtHouse.courtHouseName ?? ""; + const existingIndex = result.findIndex((r) => r.courtRoomName === courtRoomName && r.courtHouseName === courtHouseName); + + if (existingIndex === -1) { + result.push({ + courtHouseName, + courtRoomName, + lja: courtHouse.lja ?? "", + sittings + }); + } else { + result[existingIndex].sittings.push(...sittings); + } + } + } + } + + return result; +} + +function processSitting(sitting: Sitting, courtHouse: CourtList["courtHouse"], session: Session, sittings: RenderedSitting[], locale: string): void { + const sittingStartTime = formatSittingTime(sitting.sittingStart); + + for (const hearing of sitting.hearing) { + const attendanceMethod = (hearing.channel ?? []).filter(Boolean).join(", "); + const hearingType = hearing.hearingType ?? ""; + const panel = hearing.panel ?? ""; + + for (const caseItem of hearing.case ?? []) { + const baseHearingInfo = buildCaseHearingInfo(caseItem, sittingStartTime, attendanceMethod, hearingType, panel); + for (const party of caseItem.party ?? []) { + if (party.partyRole === "DEFENDANT") { + addHearingToSittings(sittings, baseHearingInfo, party, locale); + } + } + } + + for (const application of hearing.application ?? []) { + const baseHearingInfo = buildApplicationHearingInfo(application, sittingStartTime, attendanceMethod, hearingType, panel); + for (const party of application.party ?? []) { + if (party.subject === true) { + addHearingToSittings(sittings, baseHearingInfo, party, locale); + } + } + } + } +} + +function buildCaseHearingInfo(caseItem: Case, sittingStartTime: string, attendanceMethod: string, hearingType: string, panel: string) { + return { + sittingStartTime, + prosecutingAuthority: findProsecutingAuthority(caseItem.party ?? []), + attendanceMethod, + reference: caseItem.caseUrn ?? "", + applicationType: "", + caseSequenceIndicator: caseItem.caseSequenceIndicator ?? "", + hearingType, + panel, + applicationParticulars: "", + reportingRestriction: caseItem.reportingRestriction ?? false, + reportingRestrictionDetails: formatReportingRestrictionDetails(caseItem.reportingRestrictionDetails) + }; +} + +function buildApplicationHearingInfo(application: Application, sittingStartTime: string, attendanceMethod: string, hearingType: string, panel: string) { + return { + sittingStartTime, + prosecutingAuthority: findProsecutingAuthority(application.party ?? []), + attendanceMethod, + reference: application.applicationReference ?? "", + applicationType: application.applicationType ?? "", + caseSequenceIndicator: "", + hearingType, + panel, + applicationParticulars: application.applicationParticulars ?? "", + reportingRestriction: application.reportingRestriction ?? false, + reportingRestrictionDetails: formatReportingRestrictionDetails(application.reportingRestrictionDetails) + }; +} + +function addHearingToSittings(sittings: RenderedSitting[], hearingInfo: ReturnType, party: Party, locale: string): void { + const sittingHeading = buildSittingHeading(hearingInfo.sittingStartTime, hearingInfo.caseSequenceIndicator); + const partyInfo = buildPartyInfo(party, locale); + const offences = processOffences(party, locale); + + const hearing: RenderedHearing = { ...hearingInfo, partyInfo, offences }; + + const existingSitting = sittings.find((s) => s.sittingHeading === sittingHeading); + if (existingSitting) { + existingSitting.hearings.push(hearing); + } else { + sittings.push({ sittingHeading, hearings: [hearing] }); + } +} + +function buildSittingHeading(sittingStartTime: string, caseSequenceIndicator: string): string { + if (!sittingStartTime) return ""; + return caseSequenceIndicator ? `${sittingStartTime} [${caseSequenceIndicator}]` : sittingStartTime; +} + +function buildPartyInfo(party: Party, locale: string): RenderedPartyInfo { + if (party.individualDetails) { + return buildIndividualPartyInfo(party.individualDetails, locale); + } + if (party.organisationDetails) { + return buildOrganisationPartyInfo(party.organisationDetails); + } + return { name: "", dob: "", age: "", address: "", asn: "", pncId: "" }; +} + +function buildIndividualPartyInfo(details: IndividualDetails, _locale: string): RenderedPartyInfo { + return { + name: formatIndividualName(details), + dob: details.dateOfBirth ? formatDateFromIso(details.dateOfBirth) : "", + age: details.age !== undefined ? String(details.age) : "", + address: formatAddress(details.address), + asn: details.asn ?? "", + pncId: details.pncId ?? "" + }; +} + +function buildOrganisationPartyInfo(details: OrganisationDetails): RenderedPartyInfo { + return { + name: details.organisationName, + dob: "", + age: "", + address: formatAddress(details.organisationAddress), + asn: "", + pncId: "" + }; +} + +function formatIndividualName(details: IndividualDetails): string { + const nameParts = [details.individualSurname, [details.individualForenames, details.individualMiddleName].filter(Boolean).join(" ")] + .filter(Boolean) + .join(", "); + const gender = details.gender ? ` (${details.gender})` : ""; + const custody = details.inCustody ? "*" : ""; + return nameParts + gender + custody; +} + +function findProsecutingAuthority(parties: Party[]): string { + const authority = parties.find((p) => p.partyRole === "PROSECUTING_AUTHORITY" && p.organisationDetails); + return authority?.organisationDetails?.organisationName ?? ""; +} + +function processOffences(party: Party, _locale: string): RenderedOffence[] { + return (party.offence ?? []).map((offence) => ({ + offenceCode: offence.offenceCode ?? "", + offenceTitle: offence.offenceTitle ?? "", + offenceWording: offence.offenceWording ?? "", + plea: offence.plea ?? "", + pleaDate: offence.pleaDate ? formatDateFromIso(offence.pleaDate) : "", + convictionDate: offence.convictionDate ? formatDateFromIso(offence.convictionDate) : "", + adjournedDate: offence.adjournedDate ? formatDateFromIso(offence.adjournedDate) : "", + offenceLegislation: offence.offenceLegislation ?? "", + offenceMaxPenalty: offence.offenceMaxPen ?? "", + reportingRestriction: offence.reportingRestriction ?? false, + reportingRestrictionDetails: formatReportingRestrictionDetails(offence.reportingRestrictionDetails) + })); +} + +function formatCourtRoomWithJudiciary(courtRoomName: string, judiciary: Judiciary[]): string { + const judiciaryNames = judiciary + .filter((j) => j.johKnownAs) + .map((j) => j.johKnownAs as string) + .join(", "); + return judiciaryNames ? `${courtRoomName}: ${judiciaryNames}` : courtRoomName; +} + +function formatReportingRestrictionDetails(details?: string[]): string { + if (!details) return ""; + return details.filter((d) => d.length > 0).join(", "); +} + +function formatAddressLines(address?: { line?: string[]; town?: string; county?: string; postCode?: string }): string[] { + if (!address) return []; + return [...(address.line ?? []), address.town, address.county, address.postCode].filter((p): p is string => Boolean(p)); +} + +function formatAddress(address?: { line?: string[]; town?: string; county?: string; postCode?: string }): string { + if (!address) return ""; + return [...(address.line ?? []), address.town, address.county, address.postCode].filter(Boolean).join(", "); +} + +function formatDate(date: Date, locale: string): string { + return date.toLocaleDateString(locale === "cy" ? "cy-GB" : "en-GB", { + day: "numeric", + month: "long", + year: "numeric", + timeZone: "Europe/London" + }); +} + +function formatDateAndTime(isoDateTime: string, locale: string): { date: string; time: string } { + const date = new Date(isoDateTime); + return { + date: date.toLocaleDateString(locale === "cy" ? "cy-GB" : "en-GB", { + day: "numeric", + month: "long", + year: "numeric", + timeZone: "Europe/London" + }), + time: formatAmPmTime(date) + }; +} + +function formatDateFromIso(isoDateTime: string): string { + const date = new Date(isoDateTime); + const day = String(date.getUTCDate()).padStart(2, "0"); + const month = String(date.getUTCMonth() + 1).padStart(2, "0"); + const year = String(date.getUTCFullYear()); + return `${day}/${month}/${year}`; +} + +function formatSittingTime(isoDateTime: string): string { + return formatAmPmTime(new Date(isoDateTime)); +} + +function formatAmPmTime(date: Date): string { + const raw = date.toLocaleTimeString("en-GB", { + hour: "numeric", + minute: "2-digit", + hour12: true, + timeZone: "Europe/London" + }); + return raw.replace(/\s+(am|pm)$/i, (_, s) => s.toLowerCase()); +} diff --git a/libs/list-types/magistrates-standard-list/src/schemas/magistrates-standard-list.json b/libs/list-types/magistrates-standard-list/src/schemas/magistrates-standard-list.json new file mode 100644 index 000000000..ec90e607e --- /dev/null +++ b/libs/list-types/magistrates-standard-list/src/schemas/magistrates-standard-list.json @@ -0,0 +1,492 @@ +{ + "$defs": { + "address": { + "title": "Address Details", + "type": "object", + "properties": { + "line": { + "title": "Address Line", + "type": "array", + "items": { + "title": "Items", + "type": "string", + "examples": ["THE LAW COURTS"], + "pattern": "^(?!(.|\\r|\\n)*<[^>]+>)(.|\\r|\\n)*$" + } + }, + "town": { + "title": "Town", + "description": "The town for the address", + "type": "string", + "examples": ["Cambridge"], + "pattern": "^(?!(.|\\r|\\n)*<[^>]+>)(.|\\r|\\n)*$" + }, + "county": { + "title": "County", + "description": "The county for the address", + "type": "string", + "examples": ["Cambridgeshire"], + "pattern": "^(?!(.|\\r|\\n)*<[^>]+>)(.|\\r|\\n)*$" + }, + "postCode": { + "title": "Address Postcode", + "type": "string", + "examples": ["PR1 2LL"], + "pattern": "^(?!(.|\\r|\\n)*<[^>]+>)(.|\\r|\\n)*$" + } + } + }, + "judiciary": { + "title": "Judiciary", + "description": "An array of judiciary", + "type": "object", + "properties": { + "johKnownAs": { + "description": "Name and salutations, titles to be presented on publications", + "title": "JOH Known As", + "type": "string", + "examples": ["judge"], + "pattern": "^(?!(.|\\r|\\n)*<[^>]+>)(.|\\r|\\n)*$" + }, + "isPresiding": { + "description": "A flag to indicate whether a judiciary is presiding", + "title": "Is Presiding", + "type": "boolean" + } + } + }, + "party": { + "title": "Party", + "description": "A party", + "type": "object", + "properties": { + "partyRole": { + "description": "Role of the party, selectable list of roles, i.e. prosecution, solicitor defence etc", + "title": "Party Role", + "type": "string", + "examples": ["DEFENDANT", "PROSECUTING_AUTHORITY", "APPLICANT", "RESPONDENT"], + "pattern": "^(?!(.|\\r|\\n)*<[^>]+>)(.|\\r|\\n)*$" + }, + "individualDetails": { + "title": "Individual Details", + "description": "Individual Details", + "type": "object", + "properties": { + "individualForenames": { + "description": "Forename of party", + "title": "Individual Forenames", + "type": "string", + "examples": ["John Smith"], + "pattern": "^(?!(.|\\r|\\n)*<[^>]+>)(.|\\r|\\n)*$" + }, + "individualMiddleName": { + "description": "Middle name of the individual", + "title": "Individual Middle Name", + "type": "string", + "examples": ["MiddleName"], + "pattern": "^(?!(.|\\r|\\n)*<[^>]+>)(.|\\r|\\n)*$" + }, + "individualSurname": { + "description": "Surname of party", + "title": "Individual Surname", + "type": "string", + "examples": ["Surname"], + "pattern": "^(?!(.|\\r|\\n)*<[^>]+>)(.|\\r|\\n)*$" + }, + "dateOfBirth": { + "description": "Date of birth of the individual", + "title": "Date Of Birth", + "type": "string", + "examples": ["1901-01-01"], + "pattern": "^((([-+]?\\d{4}(?!\\d{2}\\b))-(0[13578]|1[02])-(0[1-9]|[12]\\d|3[01]))|(([-+]?\\d{4}(?!\\d{2}\\b))-(0[13456789]|1[012])-(0[1-9]|[12]\\d|30))|(([-+]?\\d{4}(?!\\d{2}\\b))-02-(0[1-9]|1\\d|2[0-8]))|(((1[6-9]|[2-9]\\d)(0[48]|[2468][048]|[13579][26])|((16|[2468][048]|[3579][26])00))-02-29))$" + }, + "age": { + "description": "Age of the individual", + "title": "Age", + "type": "integer", + "examples": [1] + }, + "address": { + "title": "Address", + "description": "Party Address", + "$ref": "#/$defs/address" + }, + "inCustody": { + "description": "If party is in custody or not", + "title": "In Custody", + "type": "boolean" + }, + "gender": { + "description": "Party Gender", + "title": "Gender", + "type": "string", + "examples": ["male"], + "pattern": "^(?!(.|\\r|\\n)*<[^>]+>)(.|\\r|\\n)*$" + }, + "asn": { + "description": "ASN Number", + "title": "ASN", + "type": "string", + "examples": ["ABC1234567D"], + "pattern": "^(?!(.|\\r|\\n)*<[^>]+>)(.|\\r|\\n)*$" + }, + "pncId": { + "description": "PNC Id Number", + "title": "PNC Id", + "type": "string", + "examples": ["ABC1234567D"], + "pattern": "^(?!(.|\\r|\\n)*<[^>]+>)(.|\\r|\\n)*$" + } + } + }, + "offence": { + "title": "offence", + "description": "The offence and details of offences associated with the case. It is also known as charges.", + "type": "array", + "items": { + "type": "object", + "properties": { + "offenceCode": { + "description": "Offence unique code", + "title": "Offence code", + "type": "string", + "examples": ["dd01-01"], + "pattern": "^(?!(.|\\r|\\n)*<[^>]+>)(.|\\r|\\n)*$" + }, + "offenceTitle": { + "description": "Short description of offence", + "title": "Offence title", + "type": "string", + "examples": ["drink driving"], + "pattern": "^(?!(.|\\r|\\n)*<[^>]+>)(.|\\r|\\n)*$" + }, + "offenceWording": { + "description": "Long description of offence", + "title": "Offence wording", + "type": "string", + "examples": ["driving whilst under the influence of alcohol"], + "pattern": "^(?!(?:.|\\r|\\n)*(?:<\\s*\\/[^>]*>|<[^>]*\\/>|<\\s*\\/[^&]*>|<[^&]*\\/>))(?:.|\\r|\\n)*$" + }, + "offenceMaxPen": { + "description": "Maximum penalty if found guilty", + "title": "Offence Max Penalty", + "type": "string", + "examples": ["10yrs"], + "pattern": "^(?!(.|\\r|\\n)*<[^>]+>)(.|\\r|\\n)*$" + }, + "reportingRestriction": { + "title": "Reporting Restriction", + "description": "Any reporting restrictions posed to the offence", + "type": "boolean" + }, + "reportingRestrictionDetails": { + "title": "Reporting Restrictions Detail", + "type": "array", + "items": { + "type": "string", + "pattern": "^(?!(.|\\r|\\n)*<[^>]+>)(.|\\r|\\n)*$" + } + }, + "convictionDate": { + "title": "Conviction Date", + "description": "", + "type": "string", + "examples": ["2016-09-13T23:30:52.123Z"], + "pattern": "^(\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}([.]\\d{1,9})?Z)?$" + }, + "adjournedDate": { + "title": "Adjourned Date", + "description": "", + "type": "string", + "examples": ["2016-09-13T23:30:52.123Z"], + "pattern": "^(\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}([.]\\d{1,9})?Z)?$" + }, + "plea": { + "description": "If a plea has been received", + "title": "Plea", + "type": "string", + "enum": ["GUILTY", "NOT_GUILTY", "NONE"] + }, + "pleaDate": { + "title": "Plea Date", + "description": "", + "type": "string", + "examples": ["2016-09-13T23:30:52.123Z"], + "pattern": "^(\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}([.]\\d{1,9})?Z)?$" + }, + "offenceLegislation": { + "description": "Legislation associated with the offence", + "title": "Offence Legislation", + "type": "string", + "examples": ["Road Traffic Act 1988"], + "pattern": "^(?!(.|\\r|\\n)*<[^>]+>)(.|\\r|\\n)*$" + } + } + } + }, + "organisationDetails": { + "title": "Organisation Details", + "description": "Organisation Details", + "type": "object", + "required": ["organisationName"], + "properties": { + "organisationName": { + "description": "Party name of organisation", + "title": "Organisation Name", + "type": "string", + "examples": ["A & B Solicitors"], + "pattern": "^(?!.*(?:<\\s*\\/[^>]*>|<[^>]*\\/>|<\\s*\\/[^&]*>|<[^&]*\\/>)).*$" + }, + "organisationAddress": { + "title": "Organisation Address", + "description": "Party Address", + "$ref": "#/$defs/address" + } + } + }, + "subject": { + "title": "Subject Restriction", + "description": "Party is subject of the application/case", + "type": "boolean" + } + } + } + }, + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Root", + "type": "object", + "required": ["document", "venue", "courtLists"], + "properties": { + "document": { + "title": "document", + "type": "object", + "required": ["publicationDate"], + "properties": { + "publicationDate": { + "title": "Publication date", + "description": "The date + time the list was published", + "type": "string", + "examples": ["2016-09-13T23:30:52.123Z"], + "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}([.]\\d{1,9})?Z$" + } + } + }, + "venue": { + "title": "venue", + "type": "object", + "properties": { + "venueAddress": { + "description": "Full venue address", + "$ref": "#/$defs/address" + } + } + }, + "courtLists": { + "title": "Court Lists", + "type": "array", + "items": { + "title": "Court list", + "type": "object", + "required": ["courtHouse"], + "properties": { + "courtHouse": { + "title": "Court House", + "description": "The court house that owns the court rooms", + "required": ["courtRoom"], + "type": "object", + "properties": { + "courtHouseName": { + "description": "Name of the court house", + "title": "Court House Name", + "type": "string", + "examples": ["PRESTON"], + "pattern": "^(?!(.|\\r|\\n)*<[^>]+>)(.|\\r|\\n)*$" + }, + "lja": { + "title": "Local justice area name", + "type": "string", + "maxLength": 100, + "pattern": "^(?!(.|\\r|\\n)*<[^>]+>)(.|\\r|\\n)*$" + }, + "courtRoom": { + "type": "array", + "items": { + "title": "Court Room", + "description": "The court room that owns the court rooms", + "type": "object", + "required": ["courtRoomName", "session"], + "properties": { + "courtRoomName": { + "title": "Court Room Name", + "type": "string", + "pattern": "^(?!(.|\\r|\\n)*<[^>]+>)(.|\\r|\\n)*$" + }, + "session": { + "type": "array", + "items": { + "title": "Session", + "description": "Session associated with the court in the publication", + "required": ["sittings"], + "type": "object", + "properties": { + "judiciary": { + "type": "array", + "description": "Judiciary for the session", + "title": "Judiciary", + "items": { + "type": "object", + "$ref": "#/$defs/judiciary" + } + }, + "sittings": { + "title": "Sittings", + "type": "array", + "items": { + "type": "object", + "required": ["sittingStart", "hearing"], + "properties": { + "sittingStart": { + "description": "Sitting Start Time", + "title": "Sitting Start", + "type": "string", + "examples": [], + "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}([.]\\d{1,9})?Z$" + }, + "hearing": { + "type": "array", + "items": { + "type": "object", + "description": "Hearing details of the case and associated details for the sitting", + "title": "Hearing", + "properties": { + "hearingType": { + "description": "Type of hearing being presented", + "title": "Hearing Type", + "type": "string", + "examples": ["mda"], + "pattern": "^(?!(.|\\r|\\n)*<[^>]+>)(.|\\r|\\n)*$" + }, + "case": { + "type": "array", + "items": { + "type": "object", + "title": "Case", + "required": ["caseUrn"], + "description": "Case Details", + "properties": { + "caseUrn": { + "title": "Case unique identifier", + "description": "A number identifying a case", + "type": "string", + "examples": ["ABC45684548"], + "pattern": "^(?!(.|\\r|\\n)*<[^>]+>)(.|\\r|\\n)*$" + }, + "reportingRestriction": { + "title": "Reporting Restriction", + "type": "boolean" + }, + "reportingRestrictionDetails": { + "title": "Reporting Restrictions Detail", + "type": "array", + "items": { + "type": "string", + "pattern": "^(?!(.|\\r|\\n)*<[^>]+>)(.|\\r|\\n)*$" + } + }, + "caseSequenceIndicator": { + "title": "Case Sequence Indicator", + "description": "The case sequence indicator", + "type": "string", + "examples": ["2 of 3"], + "pattern": "^(?!(.|\\r|\\n)*<[^>]+>)(.|\\r|\\n)*$" + }, + "party": { + "type": "array", + "description": "party for the case", + "title": "Party", + "items": { + "type": "object", + "$ref": "#/$defs/party" + } + } + } + } + }, + "panel": { + "title": "Panel", + "description": "Information about the panel", + "type": "string", + "examples": ["CHILD or ADULT"], + "pattern": "^(?!(.|\\r|\\n)*<[^>]+>)(.|\\r|\\n)*$" + }, + "channel": { + "type": "array", + "items": { + "type": "string", + "description": "Channel if different from in court", + "title": "Sitting Channel", + "examples": ["VIDEO HEARING"], + "pattern": "^(?!(.|\\r|\\n)*<[^>]+>)(.|\\r|\\n)*$" + } + }, + "application": { + "title": "Application", + "description": "Application associated with the case", + "type": "array", + "items": { + "type": "object", + "title": "Application", + "required": ["applicationReference"], + "properties": { + "party": { + "type": "array", + "description": "party for the case", + "title": "Party", + "items": { + "type": "object", + "$ref": "#/$defs/party" + } + }, + "applicationReference": { + "title": "Application Reference", + "description": "application reference", + "type": "string", + "examples": ["ABC1234567D"], + "pattern": "^(?!(.|\\r|\\n)*<[^>]+>)(.|\\r|\\n)*$" + }, + "applicationType": { + "title": "Application Type", + "description": "application type", + "type": "string", + "examples": ["application type example"], + "pattern": "^(?!(.|\\r|\\n)*<[^>]+>)(.|\\r|\\n)*$" + }, + "applicationParticulars": { + "title": "Application Particulars", + "description": "details of an application", + "type": "string", + "examples": ["application particulars example"], + "pattern": "^(?!(?:.|\\r|\\n)*(?:<\\s*/[^>]*>|<[^>]*\\/>|<\\s*\\/[^&]*>|<[^&]*\\/>))(?:.|\\r|\\n)*$" + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } +} diff --git a/libs/list-types/magistrates-standard-list/src/validation/json-validator.ts b/libs/list-types/magistrates-standard-list/src/validation/json-validator.ts new file mode 100644 index 000000000..19f493cb4 --- /dev/null +++ b/libs/list-types/magistrates-standard-list/src/validation/json-validator.ts @@ -0,0 +1,6 @@ +import { type ValidationResult, validateJson } from "@hmcts/publication"; +import schema from "../schemas/magistrates-standard-list.json" with { type: "json" }; + +export function validateMagistratesStandardList(jsonData: unknown): ValidationResult { + return validateJson(jsonData, schema, "1.0"); +} diff --git a/libs/list-types/magistrates-standard-list/tsconfig.json b/libs/list-types/magistrates-standard-list/tsconfig.json new file mode 100644 index 000000000..5a1c59625 --- /dev/null +++ b/libs/list-types/magistrates-standard-list/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/location/src/list-type-data.ts b/libs/location/src/list-type-data.ts index ad2cdb1f3..78e13548d 100644 --- a/libs/location/src/list-type-data.ts +++ b/libs/location/src/list-type-data.ts @@ -316,5 +316,16 @@ export const listTypeData: ListTypeData[] = [ defaultSensitivity: "Public", shortenedFriendlyName: "SJP Public List (New cases)", subJurisdictionIds: [7] + }, + { + id: 28, + name: "MAGISTRATES_STANDARD_LIST", + englishFriendlyName: "Magistrates Standard List", + welshFriendlyName: "Rhestr Safonol y Llys Ynadon", + provenance: "CRIME_IDAM", + urlPath: "magistrates-standard-list", + isNonStrategic: false, + defaultSensitivity: "Classified", + subJurisdictionIds: [7] } ]; diff --git a/libs/notifications/src/notification/notification-service.ts b/libs/notifications/src/notification/notification-service.ts index 480c8399d..73cfcb92a 100644 --- a/libs/notifications/src/notification/notification-service.ts +++ b/libs/notifications/src/notification/notification-service.ts @@ -22,6 +22,10 @@ import { extractCaseSummary as extractLondonAdminSummary, formatCaseSummaryForEmail as formatLondonAdminSummaryForEmail } from "@hmcts/london-administrative-court-daily-cause-list"; +import { + extractCaseSummary as extractMagistratesStandardSummary, + formatCaseSummaryForEmail as formatMagistratesStandardSummaryForEmail +} from "@hmcts/magistrates-standard-list"; import { prisma } from "@hmcts/postgres-prisma"; import { extractCaseSummary as extractRcjSummary, formatCaseSummaryForEmail as formatRcjSummaryForEmail } from "@hmcts/rcj-standard-daily-cause-list"; import { sendEmail } from "../govnotify/govnotify-client.js"; @@ -93,7 +97,11 @@ const EMAIL_BUILDER_REGISTRY: Partial> = { BIRMINGHAM_ADMINISTRATIVE_COURT_DAILY_CAUSE_LIST: adminCourtConfig, LEEDS_ADMINISTRATIVE_COURT_DAILY_CAUSE_LIST: adminCourtConfig, BRISTOL_CARDIFF_ADMINISTRATIVE_COURT_DAILY_CAUSE_LIST: adminCourtConfig, - MANCHESTER_ADMINISTRATIVE_COURT_DAILY_CAUSE_LIST: adminCourtConfig + MANCHESTER_ADMINISTRATIVE_COURT_DAILY_CAUSE_LIST: adminCourtConfig, + MAGISTRATES_STANDARD_LIST: { + extract: extractMagistratesStandardSummary as SummaryExtractor, + format: formatMagistratesStandardSummaryForEmail + } }; export interface NotificationResult { diff --git a/libs/publication/src/processing/service.ts b/libs/publication/src/processing/service.ts index e1f2bef6b..9f7f2d019 100644 --- a/libs/publication/src/processing/service.ts +++ b/libs/publication/src/processing/service.ts @@ -7,6 +7,7 @@ import { type CauseListData as FamilyCauseListData, generateFamilyDailyCauseList import { sendThirdPartyPublications } from "@hmcts/legacy-third-party-fulfilment"; import { getLocationById } from "@hmcts/location"; import { generateLondonAdministrativeCourtDailyCauseListPdf, type LondonAdminCourtData } from "@hmcts/london-administrative-court-daily-cause-list"; +import { generateMagistratesStandardListPdf, type MagistratesStandardList } from "@hmcts/magistrates-standard-list"; import { sendListTypePublicationNotifications, sendLocationAndCaseSubscriptionNotifications } from "@hmcts/notifications"; import { prisma } from "@hmcts/postgres-prisma"; import { generateRcjStandardDailyCauseListPdf, type StandardHearingList } from "@hmcts/rcj-standard-daily-cause-list"; @@ -75,7 +76,8 @@ const PDF_GENERATOR_REGISTRY: Partial> = { BIRMINGHAM_ADMINISTRATIVE_COURT_DAILY_CAUSE_LIST: adminCourtGenerator, LEEDS_ADMINISTRATIVE_COURT_DAILY_CAUSE_LIST: adminCourtGenerator, BRISTOL_CARDIFF_ADMINISTRATIVE_COURT_DAILY_CAUSE_LIST: adminCourtGenerator, - MANCHESTER_ADMINISTRATIVE_COURT_DAILY_CAUSE_LIST: adminCourtGenerator + MANCHESTER_ADMINISTRATIVE_COURT_DAILY_CAUSE_LIST: adminCourtGenerator, + MAGISTRATES_STANDARD_LIST: (p) => generateMagistratesStandardListPdf({ ...p, jsonData: p.jsonData as MagistratesStandardList }) }; export async function generatePublicationPdf(params: GeneratePdfParams): Promise { diff --git a/tsconfig.json b/tsconfig.json index f20acdcf2..83166a8c5 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -54,7 +54,9 @@ "@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/azure-blob": ["libs/azure-blob/src"] + "@hmcts/azure-blob": ["libs/azure-blob/src"], + "@hmcts/magistrates-standard-list": ["libs/list-types/magistrates-standard-list/src"], + "@hmcts/magistrates-standard-list/config": ["libs/list-types/magistrates-standard-list/src/config"] }, "esModuleInterop": true, "forceConsistentCasingInFileNames": true, diff --git a/yarn.lock b/yarn.lock index 0d45a5826..630d38a7d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2485,6 +2485,12 @@ __metadata: languageName: unknown linkType: soft +"@hmcts/magistrates-standard-list@workspace:*, @hmcts/magistrates-standard-list@workspace:libs/list-types/magistrates-standard-list": + version: 0.0.0-use.local + resolution: "@hmcts/magistrates-standard-list@workspace:libs/list-types/magistrates-standard-list" + languageName: unknown + linkType: soft + "@hmcts/notification@workspace:*, @hmcts/notification@workspace:libs/notification": version: 0.0.0-use.local resolution: "@hmcts/notification@workspace:libs/notification" @@ -2773,6 +2779,7 @@ __metadata: "@hmcts/list-types-common": "workspace:*" "@hmcts/location": "workspace:*" "@hmcts/london-administrative-court-daily-cause-list": "workspace:*" + "@hmcts/magistrates-standard-list": "workspace:*" "@hmcts/public-pages": "workspace:*" "@hmcts/rcj-standard-daily-cause-list": "workspace:*" "@hmcts/sjp-press-list": "workspace:*" From 29879375730ec2a5c496b582389cc0225944cdb0 Mon Sep 17 00:00:00 2001 From: KianKwa Date: Tue, 30 Jun 2026 17:04:37 +0100 Subject: [PATCH 2/6] Update yarn.lock --- yarn.lock | 2 ++ 1 file changed, 2 insertions(+) diff --git a/yarn.lock b/yarn.lock index 630d38a7d..376867471 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2488,6 +2488,8 @@ __metadata: "@hmcts/magistrates-standard-list@workspace:*, @hmcts/magistrates-standard-list@workspace:libs/list-types/magistrates-standard-list": version: 0.0.0-use.local resolution: "@hmcts/magistrates-standard-list@workspace:libs/list-types/magistrates-standard-list" + dependencies: + "@hmcts/location": "workspace:*" languageName: unknown linkType: soft From 180833c78a16071b150475579e35bbd4dd6a6855 Mon Sep 17 00:00:00 2001 From: KianKwa Date: Tue, 30 Jun 2026 18:03:03 +0100 Subject: [PATCH 3/6] qk-review changes --- .../list-types/magistrates-standard-list.scss | 25 ++ apps/web/src/assets/css/web.scss | 1 + .../magistrates-standard-list.njk | 39 +- docs/tickets/771/review.md | 356 ++++++++++++++++++ .../magistrates-standard-list/package.json | 3 +- .../assets/css/magistrates-standard-list.scss | 25 ++ .../src/config.test.ts | 24 ++ .../magistrates-standard-list/src/config.ts | 1 + .../src/locales/cy.ts | 26 +- .../src/locales/en.ts | 24 +- .../src/pdf/pdf-generator.test.ts | 146 +++++++ .../src/validation/json-validator.test.ts | 57 +++ .../magistrates-standard-list/tsconfig.json | 2 +- libs/publication/package.json | 1 + yarn.lock | 1 + 15 files changed, 689 insertions(+), 42 deletions(-) create mode 100644 apps/web/src/assets/css/list-types/magistrates-standard-list.scss create mode 100644 docs/tickets/771/review.md create mode 100644 libs/list-types/magistrates-standard-list/src/assets/css/magistrates-standard-list.scss create mode 100644 libs/list-types/magistrates-standard-list/src/config.test.ts create mode 100644 libs/list-types/magistrates-standard-list/src/pdf/pdf-generator.test.ts create mode 100644 libs/list-types/magistrates-standard-list/src/validation/json-validator.test.ts diff --git a/apps/web/src/assets/css/list-types/magistrates-standard-list.scss b/apps/web/src/assets/css/list-types/magistrates-standard-list.scss new file mode 100644 index 000000000..c40c5cabe --- /dev/null +++ b/apps/web/src/assets/css/list-types/magistrates-standard-list.scss @@ -0,0 +1,25 @@ +.linked-cases-heading { + font-weight: 700; +} + +.offence-summary { + display: inline; +} + +.add-border-bottom { + border-bottom: 1px solid #b1b4b6; +} + +.restriction-list-section { + background-color: #f3f2f1; + padding: 5px 10px; +} + +.govuk-accordion__controls { + text-align: right; +} + +.govuk-details .govuk-table__cell, +.govuk-details .govuk-table__header { + border-bottom: none; +} diff --git a/apps/web/src/assets/css/web.scss b/apps/web/src/assets/css/web.scss index 908ada64f..56de5bdba 100644 --- a/apps/web/src/assets/css/web.scss +++ b/apps/web/src/assets/css/web.scss @@ -8,6 +8,7 @@ @use "./user-management.scss"; @use "./verified-pages.scss" as verified; @use "./session-timeout.scss"; +@use "./list-types/magistrates-standard-list.scss"; @use "./list-types/sjp-filters.scss"; @use "./list-types/sjp-public-list.scss"; diff --git a/apps/web/src/pages/(list-types)/magistrates-standard-list/magistrates-standard-list.njk b/apps/web/src/pages/(list-types)/magistrates-standard-list/magistrates-standard-list.njk index 7da1e9eba..854f4b9a9 100644 --- a/apps/web/src/pages/(list-types)/magistrates-standard-list/magistrates-standard-list.njk +++ b/apps/web/src/pages/(list-types)/magistrates-standard-list/magistrates-standard-list.njk @@ -1,17 +1,5 @@ {% extends "layouts/base-template.njk" %} -{% block head %} - {{ super() }} - -{% endblock %} - {% block page_content %}
@@ -38,15 +26,24 @@

{{ t.restrictionInformationP2 }}

-

{{ t.restrictionInformationP3 }}

-

{{ t.restrictionInformationP4 }}

  • {{ t.restrictionBulletPoint1 }}
  • {{ t.restrictionBulletPoint2 }}
+

{{ t.restrictionInformationP3 }}

+

{{ t.restrictionInformationP4 }}

+
+ + {{ t.openJusticeTitle }} + +
+

{{ t.openJusticeBody }}

+
+
+

{{ t.searchCases }}

@@ -60,14 +57,14 @@ {% set courtRoomCount = 0 %} {% for room in listData %} {% set courtRoomCount = courtRoomCount + 1 %} -
diff --git a/docs/tickets/771/review.md b/docs/tickets/771/review.md new file mode 100644 index 000000000..ef8a5e437 --- /dev/null +++ b/docs/tickets/771/review.md @@ -0,0 +1,356 @@ +# Code Review: Issue #771 — Magistrates Standard List + +## Summary + +This implementation adds the `magistrates-standard-list` list type as a new lib (`libs/list-types/magistrates-standard-list/`) and a corresponding page controller in `apps/web/src/pages/(list-types)/magistrates-standard-list/`. It follows the established `civil-and-family-daily-cause-list` module pattern and is largely well-structured. The renderer, validator, PDF generator, controller, and tests are all present and all 33 tests pass. + +However, there are several issues that must be addressed before deployment: + +1. The `libs/publication/package.json` is missing the `@hmcts/magistrates-standard-list` dependency even though `service.ts` imports from it — this will cause a production build failure. +2. The `libs/list-types/magistrates-standard-list/package.json` is missing the `build:pdf-templates` script, which means the PDF template will not be copied to `dist/` during production builds. +3. The locale content deviates from the acceptance criteria in several key fields. +4. The HTML template is missing the Open Justice collapsible section and the Back to Top link, both of which are acceptance criteria. +5. Multiple custom inline styles in the HTML template violate GOV.UK Design System conventions. + +--- + +## CRITICAL Issues + +### 1. Missing dependency in `libs/publication/package.json` + +**File:** `/Users/kian.kwa/IdeaProjects/cath-service/libs/publication/package.json` + +**Problem:** `libs/publication/src/processing/service.ts` (line 10) imports directly from `@hmcts/magistrates-standard-list`, but `@hmcts/magistrates-standard-list` is not listed in `libs/publication/package.json` dependencies. The other list type packages (e.g., `@hmcts/civil-and-family-daily-cause-list`, `@hmcts/care-standards-tribunal-weekly-hearing-list`) are all explicitly listed. This package is absent. + +**Impact:** The production build for `libs/publication` will fail because the dependency cannot be resolved. PDF generation for the magistrates standard list will be broken at the build level in production. + +**Solution:** Add `"@hmcts/magistrates-standard-list": "workspace:*"` to the `dependencies` object in `libs/publication/package.json`. + +--- + +### 2. Missing `build:pdf-templates` script in `package.json` + +**File:** `/Users/kian.kwa/IdeaProjects/cath-service/libs/list-types/magistrates-standard-list/package.json` + +**Problem:** The `build` script is `"tsc"` only. The reference implementation (`civil-and-family-daily-cause-list`) uses `"build": "tsc && yarn build:nunjucks && yarn build:pdf-templates"` to copy `.njk` files to `dist/pdf/`. Without the copy step, `pdf-template.njk` will not exist at `dist/pdf/pdf-template.njk` in production, and `configureNunjucks(__dirname)` in `pdf-generator.ts` will resolve to `dist/pdf/` but find no template there. + +**Impact:** PDF generation will throw a template-not-found error in production. Development works only because the `default` export condition in `package.json` uses the `src/` path directly. + +**Solution:** Add the build script: +```json +"build": "tsc && yarn build:pdf-templates", +"build:pdf-templates": "mkdir -p dist/pdf && cp src/pdf/*.njk dist/pdf/" +``` + +--- + +### 3. Acceptance criteria locale keys not matching — `restrictionInformationHeading`, bullet points, and paragraph structure + +**File:** `/Users/kian.kwa/IdeaProjects/cath-service/libs/list-types/magistrates-standard-list/src/locales/en.ts` (lines 10–20) + +**Problem:** The ticket acceptance criteria (and the plan's template structure) specify the reporting restriction section should contain the statutory text from Children and Young Persons Act 1933 s.49, s.39, and the Criminal Procedure (Insanity) Act 1964 s.4A. The implementation uses entirely different text: generic advice about checking restrictions before publication rather than the statutory notices. The ticket specifies: + +- `restrictionInformationHeading`: "Restriction information" +- `restrictionBulletPoint1`: "the name, address or school of the young person" +- `restrictionBulletPoint2`: "any particulars calculated to lead to the identification of the young person" + +The implementation has: +- `restrictionInformationHeading`: "Restrictions on publishing or writing about these cases" (not matching) +- `restrictionBulletPoint1`: "the court directly" (not matching) +- `restrictionBulletPoint2`: "HM Courts and Tribunals Service on 0330 808 4407" (not matching) + +The Welsh locale (`cy.ts`) similarly does not match the specified Welsh keys. + +**Impact:** The page will not display the legally mandated statutory reporting restriction notices that the acceptance criteria require. This is a statutory compliance issue for a court publication service. + +**Solution:** Update the locale files to use the exact content specified in the ticket acceptance criteria for `restrictionInformationHeading`, `restrictionInformationP1` through `restrictionInformationP4`, `restrictionBulletPoint1`, `restrictionBulletPoint2`, and all corresponding Welsh values. + +--- + +## HIGH PRIORITY Issues + +### 4. Open Justice collapsible section is absent from the HTML template + +**File:** `/Users/kian.kwa/IdeaProjects/cath-service/apps/web/src/pages/(list-types)/magistrates-standard-list/magistrates-standard-list.njk` + +**Problem:** The ticket acceptance criteria states "Open Justice collapsible section present". The plan specifies a `govuk-details` component with `t.openJusticeTitle`. The template has no Open Justice section at all. The locale files do not contain `openJusticeTitle` or any Open Justice content. The only `govuk-details` usage in the template is for individual offence disclosure, not a top-level Open Justice section. + +**Impact:** An acceptance criterion is unmet. The page differs from the specified design. + +**Solution:** Add a `govuk-details` component between the reporting restriction section and the case search input, using `t.openJusticeTitle` as the summary text. + +--- + +### 5. "Back to top" link is absent from the HTML template + +**File:** `/Users/kian.kwa/IdeaProjects/cath-service/apps/web/src/pages/(list-types)/magistrates-standard-list/magistrates-standard-list.njk` + +**Problem:** The ticket acceptance criteria specifies `linkToTop` as a required content key ("Back to top" / "Yn ôl i'r brig"), and the plan confirms a back-to-top link at the bottom of the page. Neither the locale files nor the template contain this key or link. + +**Impact:** An acceptance criterion is unmet. + +**Solution:** Add `linkToTop` to both locale files and add an anchor link at the bottom of the template (before or after the data source attribution) linking to `#top` or `#main-content`. + +--- + +### 6. Inline styles in the HTML template violate GOV.UK Design System conventions + +**File:** `/Users/kian.kwa/IdeaProjects/cath-service/apps/web/src/pages/(list-types)/magistrates-standard-list/magistrates-standard-list.njk` (lines 3–13, and line 213) + +**Problem:** The template uses a ` @@ -53,7 +73,10 @@

{{ t.restrictionInformationHeading }}

{{ t.restrictionInformationP1 }}

-

{{ t.restrictionInformationBoldText }}

+
+ + {{ t.restrictionInformationBoldText }} +

{{ t.restrictionInformationP2 }}

{{ t.restrictionInformationP3 }}

{{ t.restrictionInformationP4 }}